snapshot of https://github.com/pion/webrtc.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s

This commit is contained in:
upstream-mirror
2026-09-15 20:06:07 +05:00
commit 8acb1a3a29
394 changed files with 78746 additions and 0 deletions

6
.codacy.yaml Normal file
View File

@@ -0,0 +1,6 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
exclude_paths:
- examples/examples.json

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["standard"]
}

5
.github/.ci.conf vendored Normal file
View File

@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
GO_JS_WASM_EXEC=${PWD}/test-wasm/go_js_wasm_exec
EXCLUDED_CONTRIBUTORS=('Josh Bleecher Snyder' 'Sidney San Martín')

4
.github/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
.goassets

31
.github/fetch-scripts.sh vendored Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
set -eu
SCRIPT_PATH="$(realpath "$(dirname "$0")")"
GOASSETS_PATH="${SCRIPT_PATH}/.goassets"
GOASSETS_REF=${GOASSETS_REF:-main}
if [ -d "${GOASSETS_PATH}" ]; then
if ! git -C "${GOASSETS_PATH}" diff --exit-code; then
echo "${GOASSETS_PATH} has uncommitted changes" >&2
exit 1
fi
git -C "${GOASSETS_PATH}" fetch origin
git -C "${GOASSETS_PATH}" checkout ${GOASSETS_REF}
git -C "${GOASSETS_PATH}" reset --hard origin/${GOASSETS_REF}
else
git clone -b ${GOASSETS_REF} https://github.com/pion/.goassets.git "${GOASSETS_PATH}"
fi

19
.github/install-hooks.sh vendored Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/sh
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
SCRIPT_PATH="$(realpath "$(dirname "$0")")"
. ${SCRIPT_PATH}/fetch-scripts.sh
cp "${GOASSETS_PATH}/hooks/commit-msg.sh" "${SCRIPT_PATH}/../.git/hooks/commit-msg"
cp "${GOASSETS_PATH}/hooks/pre-commit.sh" "${SCRIPT_PATH}/../.git/hooks/pre-commit"

BIN
.github/pion-gopher-webrtc.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT

20
.github/workflows/api.yaml vendored Normal file
View File

@@ -0,0 +1,20 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: API
on:
pull_request:
jobs:
check:
uses: pion/.goassets/.github/workflows/api.reusable.yml@main

22
.github/workflows/browser-e2e.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: Browser E2E
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
e2e-test:
name: Test
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v7
- name: test
run: |
docker build -t pion-webrtc-e2e -f e2e/Dockerfile .
docker run -i --rm pion-webrtc-e2e

28
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: CodeQL
on:
workflow_dispatch:
schedule:
- cron: '23 5 * * 0'
pull_request:
branches:
- main
paths:
- '**.go'
jobs:
analyze:
uses: pion/.goassets/.github/workflows/codeql-analysis.reusable.yml@main

20
.github/workflows/examples-tests.yaml vendored Normal file
View File

@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: Examples Tests
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
pion-to-pion-test:
name: Test
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v7
- name: test
run: cd examples/pion-to-pion && ./test.sh

27
.github/workflows/fuzz.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: Fuzz
on:
push:
branches:
- main
schedule:
- cron: "0 */8 * * *"
jobs:
fuzz:
uses: pion/.goassets/.github/workflows/fuzz.reusable.yml@main
with:
go-version: "1.25" # auto-update/latest-go-version
fuzz-time: "60s"

22
.github/workflows/lint.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: Lint
on:
pull_request:
jobs:
lint:
uses: pion/.goassets/.github/workflows/lint.reusable.yml@main
with:
golangci-lint-version: v2.10.1

24
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
uses: pion/.goassets/.github/workflows/release.reusable.yml@main
with:
go-version: "1.25" # auto-update/latest-go-version

View File

@@ -0,0 +1,24 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: Fix go.sum
on:
push:
branches:
- renovate/*
jobs:
fix:
uses: pion/.goassets/.github/workflows/renovate-go-sum-fix.reusable.yml@main
secrets:
token: ${{ secrets.PIONBOT_PRIVATE_KEY }}

22
.github/workflows/reuse.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: REUSE Compliance Check
on:
push:
pull_request:
jobs:
lint:
uses: pion/.goassets/.github/workflows/reuse.reusable.yml@main

19
.github/workflows/standardjs.yaml vendored Normal file
View File

@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: StandardJS
on:
pull_request:
types:
- opened
- edited
- synchronize
jobs:
StandardJS:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24.x
- run: npm install standard
- run: npx standard

63
.github/workflows/test.yaml vendored Normal file
View File

@@ -0,0 +1,63 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: Test
on:
push:
branches:
- main
pull_request:
jobs:
test:
uses: pion/.goassets/.github/workflows/test.reusable.yml@main
strategy:
matrix:
go: ["1.25", "1.24"] # auto-update/supported-go-version-list
fail-fast: false
with:
go-version: ${{ matrix.go }}
secrets: inherit
test-i386:
uses: pion/.goassets/.github/workflows/test-i386.reusable.yml@main
strategy:
matrix:
go: ["1.25", "1.24"] # auto-update/supported-go-version-list
fail-fast: false
with:
go-version: ${{ matrix.go }}
test-windows:
uses: pion/.goassets/.github/workflows/test-windows.reusable.yml@main
strategy:
matrix:
go: ["1.25", "1.24"] # auto-update/supported-go-version-list
fail-fast: false
with:
go-version: ${{ matrix.go }}
test-macos:
uses: pion/.goassets/.github/workflows/test-macos.reusable.yml@main
strategy:
matrix:
go: ["1.25", "1.24"] # auto-update/supported-go-version-list
fail-fast: false
with:
go-version: ${{ matrix.go }}
test-wasm:
uses: pion/.goassets/.github/workflows/test-wasm.reusable.yml@main
with:
go-version: "1.25" # auto-update/latest-go-version
secrets: inherit

25
.github/workflows/tidy-check.yaml vendored Normal file
View File

@@ -0,0 +1,25 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
# If this repository should have package specific CI config,
# remove the repository name from .goassets/.github/workflows/assets-sync.yml.
#
# If you want to update the shared CI config, send a PR to
# https://github.com/pion/.goassets instead of this repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: Go mod tidy
on:
pull_request:
push:
branches:
- main
jobs:
tidy:
uses: pion/.goassets/.github/workflows/tidy-check.reusable.yml@main
with:
go-version: "1.25" # auto-update/latest-go-version

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
### JetBrains IDE ###
#####################
.idea/
### Emacs Temporary Files ###
#############################
*~
### Folders ###
###############
bin/
vendor/
node_modules/
### Files ###
#############
*.ivf
*.ogg
tags
cover.out
*.sw[poe]
*.wasm
examples/sfu-ws/cert.pem
examples/sfu-ws/key.pem
wasm_exec.js

155
.golangci.yml Normal file
View File

@@ -0,0 +1,155 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
version: "2"
linters:
enable:
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers
- bidichk # Checks for dangerous unicode character sequences
- bodyclose # checks whether HTTP response body is closed successfully
- containedctx # containedctx is a linter that detects struct contained context.Context field
- contextcheck # check the function whether use a non-inherited context
- cyclop # checks function and package cyclomatic complexity
- decorder # check declaration order and count of types, constants, variables and functions
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
- dupl # Tool for code clone detection
- durationcheck # check for two durations multiplied together
- err113 # Golang linter to check the errors handling expressions
- errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases
- errchkjson # Checks types passed to the json encoding functions. Reports unsupported types and optionally reports occations, where the check for the returned error can be omitted.
- errname # Checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error`.
- errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13.
- exhaustive # check exhaustiveness of enum switch statements
- forbidigo # Forbids identifiers
- forcetypeassert # finds forced type assertions
- gochecknoglobals # Checks that no globals are present in Go code
- gocognit # Computes and checks the cognitive complexity of functions
- goconst # Finds repeated strings that could be replaced by a constant
- gocritic # The most opinionated Go source code linter
- gocyclo # Computes and checks the cyclomatic complexity of functions
- godot # Check if comments end in a period
- godox # Tool for detection of FIXME, TODO and other comment keywords
- goheader # Checks is file header matches to pattern
- gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod.
- goprintffuncname # Checks that printf-like functions are named with `f` at the end
- gosec # Inspects source code for security problems
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
- grouper # An analyzer to analyze expression groups.
- importas # Enforces consistent import aliases
- ineffassign # Detects when assignments to existing variables are not used
- lll # Reports long lines
- maintidx # maintidx measures the maintainability index of each function.
- makezero # Finds slice declarations with non-zero initial length
- misspell # Finds commonly misspelled English words in comments
- modernize # Replace and suggests simplifications to code
- nakedret # Finds naked returns in functions greater than a specified function length
- nestif # Reports deeply nested if statements
- nilerr # Finds the code that returns nil even if it checks that the error is not nil.
- nilnil # Checks that there is no simultaneous return of `nil` error and an invalid value.
- nlreturn # nlreturn checks for a new line before return and branch statements to increase code clarity
- noctx # noctx finds sending http request without context.Context
- predeclared # find code that shadows one of Go's predeclared identifiers
- revive # golint replacement, finds style mistakes
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks
- tagliatelle # Checks the struct tags.
- thelper # thelper detects golang test helpers without t.Helper() call and checks the consistency of test helpers
- unconvert # Remove unnecessary type conversions
- unparam # Reports unused function parameters
- unused # Checks Go code for unused constants, variables, functions and types
- varnamelen # checks that the length of a variable's name matches its scope
- wastedassign # wastedassign finds wasted assignment statements
- whitespace # Tool for detection of leading and trailing whitespace
disable:
- depguard # Go linter that checks if package imports are in a list of acceptable packages
- funlen # Tool for detection of long functions
- gochecknoinits # Checks that no init functions are present in Go code
- gomodguard # Allow and block list linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations.
- interfacebloat # A linter that checks length of interface.
- ireturn # Accept Interfaces, Return Concrete Types
- mnd # An analyzer to detect magic numbers
- nolintlint # Reports ill-formed or insufficient nolint directives
- paralleltest # paralleltest detects missing usage of t.Parallel() method in your Go test
- prealloc # Finds slice declarations that could potentially be preallocated
- promlinter # Check Prometheus metrics naming via promlint
- rowserrcheck # checks whether Err of rows is checked successfully
- sqlclosecheck # Checks that sql.Rows and sql.Stmt are closed.
- testpackage # linter that makes you use a separate _test package
- tparallel # tparallel detects inappropriate usage of t.Parallel() method in your Go test codes
- wrapcheck # Checks that errors returned from external packages are wrapped
- wsl # Whitespace Linter - Forces you to use empty lines!
settings:
staticcheck:
checks:
- all
- -QF1008 # "could remove embedded field", to keep it explicit!
- -QF1003 # "could use tagged switch on enum", Cases conflicts with exhaustive!
exhaustive:
default-signifies-exhaustive: true
forbidigo:
forbid:
- pattern: ^fmt.Print(f|ln)?$
- pattern: ^log.(Panic|Fatal|Print)(f|ln)?$
- pattern: ^os.Exit$
- pattern: ^panic$
- pattern: ^print(ln)?$
- pattern: ^testing.T.(Error|Errorf|Fatal|Fatalf|Fail|FailNow)$
pkg: ^testing$
msg: use testify/assert instead
analyze-types: true
gomodguard:
blocked:
modules:
- github.com/pkg/errors:
recommendations:
- errors
govet:
enable:
- shadow
revive:
rules:
# Prefer 'any' type alias over 'interface{}' for Go 1.18+ compatibility
- name: use-any
severity: warning
disabled: false
# Keep package documentation consistent and avoid duplicate package comments.
- name: package-comments
severity: warning
disabled: false
misspell:
locale: US
varnamelen:
max-distance: 12
min-name-length: 2
ignore-type-assert-ok: true
ignore-map-index-ok: true
ignore-chan-recv-ok: true
ignore-decls:
- i int
- n int
- w io.Writer
- r io.Reader
- b []byte
exclusions:
generated: lax
rules:
- linters:
- forbidigo
- gocognit
path: (examples|main\.go)
- linters:
- gocognit
path: _test\.go
- linters:
- forbidigo
path: cmd
formatters:
enable:
- gci # Gci control golang package import order and make it always deterministic.
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification
- gofumpt # Gofumpt checks whether code was gofumpt-ed.
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports
exclusions:
generated: lax
issues:
max-issues-per-linter: 0
max-same-issues: 0

5
.goreleaser.yml Normal file
View File

@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
builds:
- skip: true

11
.reuse/dep5 Normal file
View File

@@ -0,0 +1,11 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: Pion
Source: https://github.com/pion/
Files: README.md DESIGN.md **/README.md AUTHORS.txt renovate.json go.mod go.sum **/go.mod **/go.sum .eslintrc.json package.json examples.json sfu-ws/flutter/.gitignore sfu-ws/flutter/pubspec.yaml c-data-channels/webrtc.h examples/examples.json yarn.lock
Copyright: 2026 The Pion community <https://pion.ly>
License: MIT
Files: testdata/seed/* testdata/fuzz/* **/testdata/fuzz/* api/*.txt
Copyright: 2026 The Pion community <https://pion.ly>
License: CC0-1.0

43
DESIGN.md Normal file
View File

@@ -0,0 +1,43 @@
<h1 align="center">
Design
</h1>
WebRTC is a powerful, but complicated technology you can build amazing things with, it comes with a steep learning curve though.
Using WebRTC in the browser is easy, but outside the browser is more of a challenge. There are multiple libraries, and they all have
varying levels of quality. Most are also difficult to build, and depend on libraries that aren't available in repos or portable.
Pion WebRTC aims to solve all that! Built in native Go you should be able to send and receive media and text from anywhere with minimal headache.
These are the design principals that drive Pion WebRTC and hopefully convince you it is worth a try.
### Portable
Pion WebRTC is written in Go and extremely portable. Anywhere Golang runs, Pion WebRTC should work as well! Instead of dealing with complicated
cross-compiling of multiple libraries, you now can run anywhere with one `go build`
### Flexible
When possible we leave all decisions to the user. When choice is possible (like what logging library is used) we defer to the developer.
### Simple API
If you know how to use WebRTC in your browser, you know how to use Pion WebRTC.
We try our best just to duplicate the Javascript API, so your code can look the same everywhere.
If this is your first time using WebRTC, don't worry! We have multiple [examples](https://github.com/pion/webrtc/tree/main/examples) and [GoDoc](https://pkg.go.dev/github.com/pion/webrtc/v4)
### Bring your own media
Pion WebRTC doesn't make any assumptions about where your audio, video or text come from. You can use FFmpeg, GStreamer, MLT or just serve a video file.
This library only serves to transport, not create media.
### Safe
Golang provides a great foundation to build safe network services.
Especially when running a networked service that is highly concurrent bugs can be devastating.
### Readable
If code comes from an RFC we try to make sure everything is commented with a link to the spec.
This makes learning and debugging easier, this WebRTC library was written to also serve as a guide for others.
### Tested
Every commit is tested via travis-ci Go provides fantastic facilities for testing, and more will be added as time goes on.
### Shared libraries
Every Pion project is built using shared libraries, allowing others to review and reuse our libraries.
### Community
The most important part of Pion is the community. This projects only exist because of individual contributions. We aim to be radically open and do everything we can to support those that make Pion possible.

9
LICENSE Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 The Pion community <https://pion.ly>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

9
LICENSES/MIT.txt Normal file
View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
<h1 align="center">
<a href="https://pion.ly"><img src="./.github/pion-gopher-webrtc.png" alt="Pion WebRTC" height="250px"></a>
<br>
Pion WebRTC
<br>
</h1>
<h4 align="center">A pure Go implementation of the WebRTC API</h4>
<p align="center">
<a href="https://pion.ly"><img src="https://img.shields.io/badge/pion-webrtc-gray.svg?longCache=true&colorB=brightgreen" alt="Pion WebRTC"></a>
<a href="https://sourcegraph.com/github.com/pion/webrtc?badge"><img src="https://sourcegraph.com/github.com/pion/webrtc/-/badge.svg" alt="Sourcegraph Widget"></a>
<a href="https://discord.gg/PngbdqpFbt"><img src="https://img.shields.io/badge/join-us%20on%20discord-gray.svg?longCache=true&logo=discord&colorB=brightblue" alt="join us on Discord"></a> <a href="https://bsky.app/profile/pion.ly"><img src="https://img.shields.io/badge/follow-us%20on%20bluesky-gray.svg?longCache=true&logo=bluesky&colorB=brightblue" alt="Follow us on Bluesky"></a> <a href="https://twitter.com/_pion?ref_src=twsrc%5Etfw"><img src="https://img.shields.io/twitter/url.svg?label=Follow%20%40_pion&style=social&url=https%3A%2F%2Ftwitter.com%2F_pion" alt="Twitter Widget"></a>
<a href="https://github.com/pion/awesome-pion" alt="Awesome Pion"><img src="https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg"></a>
<br>
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/pion/webrtc/test.yaml">
<a href="https://pkg.go.dev/github.com/pion/webrtc/v4"><img src="https://pkg.go.dev/badge/github.com/pion/webrtc/v4.svg" alt="Go Reference"></a>
<a href="https://codecov.io/gh/pion/webrtc"><img src="https://codecov.io/gh/pion/webrtc/branch/main/graph/badge.svg" alt="Coverage Status"></a>
<a href="https://goreportcard.com/report/github.com/pion/webrtc/v4"><img src="https://goreportcard.com/badge/github.com/pion/webrtc/v4" alt="Go Report Card"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
</p>
<br>
### New Release
Pion WebRTC v4.0.0 has been released! See the [release notes](https://github.com/pion/webrtc/wiki/Release-WebRTC@v4.0.0) to learn about new features and breaking changes.
If you aren't able to upgrade yet check the [tags](https://github.com/pion/webrtc/tags) for the latest `v3` release.
We would love your feedback! Please create GitHub issues or Join the [Discord](https://discord.gg/PngbdqpFbt) to follow development and speak with the maintainers.
-----
### Usage
[Go Modules](https://blog.golang.org/using-go-modules) are mandatory for using Pion WebRTC. So make sure you set `export GO111MODULE=on`, and explicitly specify `/v4` (or an earlier version) when importing.
**[example applications](examples/README.md)** contains code samples of common things people build with Pion WebRTC.
**[example-webrtc-applications](https://github.com/pion/example-webrtc-applications)** contains more full featured examples that use 3rd party libraries.
**[awesome-pion](https://github.com/pion/awesome-pion)** contains projects that have used Pion, and serve as real world examples of usage.
**[GoDoc](https://pkg.go.dev/github.com/pion/webrtc/v4)** is an auto generated API reference. All our Public APIs are commented.
**[FAQ](https://github.com/pion/webrtc/wiki/FAQ)** has answers to common questions. If you have a question not covered please ask in [Discord](https://discord.gg/PngbdqpFbt) we are always looking to expand it.
Now go build something awesome! Here are some **ideas** to get your creative juices flowing:
* Send a video file to multiple browser in real time for perfectly synchronized movie watching.
* Send a webcam on an embedded device to your browser with no additional server required!
* Securely send data between two servers, without using pub/sub.
* Record your webcam and do special effects server side.
* Build a conferencing application that processes audio/video and make decisions off of it.
* Remotely control a robots and stream its cameras in realtime.
### Need Help?
Check out [WebRTC for the Curious](https://webrtcforthecurious.com). A book about WebRTC in depth, not just about the APIs.
Learn the full details of ICE, SCTP, DTLS, SRTP, and how they work together to make up the WebRTC stack. This is also a great
resource if you are trying to debug. Learn the tools of the trade and how to approach WebRTC issues. This book is vendor
agnostic and will not have any Pion specific information.
Pion has an active community on [Discord](https://discord.gg/PngbdqpFbt). Please ask for help about anything, questions don't have to be Pion specific!
Come share your interesting project you are working on. We are here to support you.
One of the maintainers of Pion [Sean-Der](https://github.com/sean-der) is available to help. Schedule at [siobud.com/meeting](https://siobud.com/meeting)
He is available to talk about Pion or general WebRTC questions, feel free to reach out about anything!
### Features
#### PeerConnection API
* Go implementation of [webrtc-pc](https://w3c.github.io/webrtc-pc/) and [webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
* DataChannels
* Send/Receive audio and video
* Renegotiation
* Plan-B and Unified Plan
* [SettingEngine](https://pkg.go.dev/github.com/pion/webrtc/v4#SettingEngine) for Pion specific extensions
#### Connectivity
* Full ICE Agent
* ICE Restart
* Trickle ICE
* STUN
* TURN (UDP, TCP, DTLS and TLS)
* mDNS candidates
#### DataChannels
* Ordered/Unordered
* Lossy/Lossless
#### Media
* API with direct RTP/RTCP access
* Opus, PCM, H264, VP8 and VP9 packetizer
* API also allows developer to pass their own packetizer
* IVF, Ogg, H264 and Matroska provided for easy sending and saving
* [getUserMedia](https://github.com/pion/mediadevices) implementation (Requires Cgo)
* Easy integration with x264, libvpx, GStreamer and ffmpeg.
* [Simulcast](https://github.com/pion/webrtc/tree/main/examples/simulcast)
* [SVC](https://github.com/pion/rtp/blob/main/codecs/vp9_packet.go#L138)
* [NACK](https://github.com/pion/interceptor/pull/4)
* [Sender/Receiver Reports](https://github.com/pion/interceptor/tree/main/pkg/report)
* [Transport Wide Congestion Control Feedback](https://github.com/pion/interceptor/tree/main/pkg/twcc)
* [Bandwidth Estimation](https://github.com/pion/webrtc/tree/main/examples/bandwidth-estimation-from-disk)
#### Security
* TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 and TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA for DTLS v1.2
* SRTP_AEAD_AES_256_GCM and SRTP_AES128_CM_HMAC_SHA1_80 for SRTP
* Hardware acceleration available for GCM suites
#### Pure Go
* No Cgo usage
* Wide platform support
* Windows, macOS, Linux, FreeBSD
* iOS, Android
* [WASM](https://github.com/pion/webrtc/wiki/WebAssembly-Development-and-Testing) see [examples](examples/README.md#webassembly)
* 386, amd64, arm, mips, ppc64
* Easy to build *Numbers generated on Intel(R) Core(TM) i5-2520M CPU @ 2.50GHz*
* **Time to build examples/play-from-disk** - 0.66s user 0.20s system 306% cpu 0.279 total
* **Time to run entire test suite** - 25.60s user 9.40s system 45% cpu 1:16.69 total
* Tools to measure performance [provided](https://github.com/pion/rtsp-bench)
### Roadmap
The library is in active development, please refer to the [roadmap](https://github.com/pion/webrtc/issues/9) to track our major milestones.
We also maintain a list of [Big Ideas](https://github.com/pion/webrtc/wiki/Big-Ideas) these are things we want to build but don't have a clear plan or the resources yet.
If you are looking to get involved this is a great place to get started! We would also love to hear your ideas! Even if you can't implement it yourself, it could inspire others.
### Sponsoring
Work on Pion's congestion control and bandwidth estimation was funded through the [User-Operated Internet](https://nlnet.nl/useroperated/) fund, a fund established by [NLnet](https://nlnet.nl/) made possible by financial support from the [PKT Community](https://pkt.cash/)/[The Network Steward](https://pkt.cash/network-steward) and stichting [Technology Commons Trust](https://technologycommons.org/).
### Community
Pion has an active community on the [Discord](https://discord.gg/PngbdqpFbt).
Follow the [Pion Bluesky](https://bsky.app/profile/pion.ly) or [Pion Twitter](https://twitter.com/_pion) for project updates and important WebRTC news.
We are always looking to support **your projects**. Please reach out if you have something to build!
If you need commercial support or don't want to use public methods you can contact us at [team@pion.ly](mailto:team@pion.ly)
### Contributing
Check out the [contributing wiki](https://github.com/pion/webrtc/wiki/Contributing) to join the group of amazing people making this project possible
### License
MIT License - see [LICENSE](LICENSE) for full text

96
api.go Normal file
View File

@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package webrtc
import (
"github.com/pion/interceptor"
"github.com/pion/logging"
)
// API allows configuration of a PeerConnection
// with APIs that are available in the standard. This
// lets you set custom behavior via the SettingEngine, configure
// codecs via the MediaEngine and define custom media behaviors via
// Interceptors.
type API struct {
settingEngine *SettingEngine
mediaEngine *MediaEngine
interceptorRegistry *interceptor.Registry
interceptor interceptor.Interceptor // Generated per PeerConnection
}
// NewAPI Creates a new API object for keeping semi-global settings to WebRTC objects
//
// It uses the default Codecs and Interceptors unless you customize them
// using WithMediaEngine and WithInterceptorRegistry respectively.
func NewAPI(options ...func(*API)) *API {
api := &API{
interceptor: &interceptor.NoOp{},
settingEngine: &SettingEngine{},
}
for _, o := range options {
o(api)
}
if api.settingEngine.LoggerFactory == nil {
api.settingEngine.LoggerFactory = logging.NewDefaultLoggerFactory()
}
logger := api.settingEngine.LoggerFactory.NewLogger("api")
if api.mediaEngine == nil {
api.mediaEngine = &MediaEngine{}
err := api.mediaEngine.RegisterDefaultCodecs()
if err != nil {
logger.Errorf("Failed to register default codecs %s", err)
}
}
if api.interceptorRegistry == nil {
api.interceptorRegistry = &interceptor.Registry{}
err := RegisterDefaultInterceptorsWithOptions(api.mediaEngine, api.interceptorRegistry,
WithInterceptorLoggerFactory(api.settingEngine.LoggerFactory))
if err != nil {
logger.Errorf("Failed to register default interceptors %s", err)
}
}
return api
}
// WithMediaEngine allows providing a MediaEngine to the API.
// Settings can be changed after passing the engine to an API.
// When a PeerConnection is created the MediaEngine is copied
// and no more changes can be made.
func WithMediaEngine(m *MediaEngine) func(a *API) {
return func(a *API) {
a.mediaEngine = m
if a.mediaEngine == nil {
a.mediaEngine = &MediaEngine{}
}
}
}
// WithSettingEngine allows providing a SettingEngine to the API.
// Settings should not be changed after passing the engine to an API.
func WithSettingEngine(s SettingEngine) func(a *API) {
return func(a *API) {
a.settingEngine = &s
}
}
// WithInterceptorRegistry allows providing Interceptors to the API.
// Settings should not be changed after passing the registry to an API.
func WithInterceptorRegistry(ir *interceptor.Registry) func(a *API) {
return func(a *API) {
a.interceptorRegistry = ir
if a.interceptorRegistry == nil {
a.interceptorRegistry = &interceptor.Registry{}
}
}
}

35
api_js.go Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package webrtc
// API bundles the global functions of the WebRTC and ORTC API.
type API struct {
settingEngine *SettingEngine
}
// NewAPI Creates a new API object for keeping semi-global settings to WebRTC objects
func NewAPI(options ...func(*API)) *API {
a := &API{}
for _, o := range options {
o(a)
}
if a.settingEngine == nil {
a.settingEngine = &SettingEngine{}
}
return a
}
// WithSettingEngine allows providing a SettingEngine to the API.
// Settings should not be changed after passing the engine to an API.
func WithSettingEngine(s SettingEngine) func(a *API) {
return func(a *API) {
a.settingEngine = &s
}
}

43
api_test.go Normal file
View File

@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package webrtc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewAPI(t *testing.T) {
api := NewAPI()
assert.NotNil(t, api.settingEngine, "failed to init settings engine")
assert.NotNil(t, api.mediaEngine, "failed to init media engine")
assert.NotNil(t, api.interceptorRegistry, "failed to init interceptor registry")
}
func TestNewAPI_Options(t *testing.T) {
s := SettingEngine{}
s.DetachDataChannels()
api := NewAPI(
WithSettingEngine(s),
)
assert.True(t, api.settingEngine.detach.DataChannels, "failed to set settings engine")
assert.NotEmpty(t, api.mediaEngine.audioCodecs, "failed to set audio codecs")
assert.NotEmpty(t, api.mediaEngine.videoCodecs, "failed to set video codecs")
}
func TestNewAPI_OptionsDefaultize(t *testing.T) {
api := NewAPI(
WithMediaEngine(nil),
WithInterceptorRegistry(nil),
)
assert.NotNil(t, api.settingEngine)
assert.NotNil(t, api.mediaEngine)
assert.NotNil(t, api.interceptorRegistry)
}

85
bundlepolicy.go Normal file
View File

@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"encoding/json"
)
// BundlePolicy affects which media tracks are negotiated if the remote
// endpoint is not bundle-aware, and what ICE candidates are gathered. If the
// remote endpoint is bundle-aware, all media tracks and data channels are
// bundled onto the same transport.
type BundlePolicy int
const (
// BundlePolicyUnknown is the enum's zero-value.
BundlePolicyUnknown BundlePolicy = iota
// BundlePolicyBalanced indicates to gather ICE candidates for each
// media type in use (audio, video, and data). If the remote endpoint is
// not bundle-aware, negotiate only one audio and video track on separate
// transports.
BundlePolicyBalanced
// BundlePolicyMaxCompat indicates to gather ICE candidates for each
// track. If the remote endpoint is not bundle-aware, negotiate all media
// tracks on separate transports.
BundlePolicyMaxCompat
// BundlePolicyMaxBundle indicates to gather ICE candidates for only
// one track. If the remote endpoint is not bundle-aware, negotiate only
// one media track.
BundlePolicyMaxBundle
)
// This is done this way because of a linter.
const (
bundlePolicyBalancedStr = "balanced"
bundlePolicyMaxCompatStr = "max-compat"
bundlePolicyMaxBundleStr = "max-bundle"
)
func newBundlePolicy(raw string) BundlePolicy {
switch raw {
case bundlePolicyBalancedStr:
return BundlePolicyBalanced
case bundlePolicyMaxCompatStr:
return BundlePolicyMaxCompat
case bundlePolicyMaxBundleStr:
return BundlePolicyMaxBundle
default:
return BundlePolicyUnknown
}
}
func (t BundlePolicy) String() string {
switch t {
case BundlePolicyBalanced:
return bundlePolicyBalancedStr
case BundlePolicyMaxCompat:
return bundlePolicyMaxCompatStr
case BundlePolicyMaxBundle:
return bundlePolicyMaxBundleStr
default:
return ErrUnknownType.Error()
}
}
// UnmarshalJSON parses the JSON-encoded data and stores the result.
func (t *BundlePolicy) UnmarshalJSON(b []byte) error {
var val string
if err := json.Unmarshal(b, &val); err != nil {
return err
}
*t = newBundlePolicy(val)
return nil
}
// MarshalJSON returns the JSON encoding.
func (t BundlePolicy) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}

50
bundlepolicy_test.go Normal file
View File

@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewBundlePolicy(t *testing.T) {
testCases := []struct {
policyString string
expectedPolicy BundlePolicy
}{
{ErrUnknownType.Error(), BundlePolicyUnknown},
{"balanced", BundlePolicyBalanced},
{"max-compat", BundlePolicyMaxCompat},
{"max-bundle", BundlePolicyMaxBundle},
}
for i, testCase := range testCases {
assert.Equal(t,
testCase.expectedPolicy,
newBundlePolicy(testCase.policyString),
"testCase: %d %v", i, testCase,
)
}
}
func TestBundlePolicy_String(t *testing.T) {
testCases := []struct {
policy BundlePolicy
expectedString string
}{
{BundlePolicyUnknown, ErrUnknownType.Error()},
{BundlePolicyBalanced, "balanced"},
{BundlePolicyMaxCompat, "max-compat"},
{BundlePolicyMaxBundle, "max-bundle"},
}
for i, testCase := range testCases {
assert.Equal(t,
testCase.expectedString,
testCase.policy.String(),
"testCase: %d %v", i, testCase,
)
}
}

262
certificate.go Normal file
View File

@@ -0,0 +1,262 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"strings"
"time"
"github.com/pion/dtls/v3/pkg/crypto/fingerprint"
"github.com/pion/webrtc/v4/pkg/rtcerr"
)
// Certificate represents a x509Cert used to authenticate WebRTC communications.
type Certificate struct {
privateKey crypto.PrivateKey
x509Cert *x509.Certificate
statsID string
}
// NewCertificate generates a new x509 compliant Certificate to be used
// by DTLS for encrypting data sent over the wire. This method differs from
// GenerateCertificate by allowing to specify a template x509.Certificate to
// be used in order to define certificate parameters.
func NewCertificate(key crypto.PrivateKey, tpl x509.Certificate) (*Certificate, error) {
var err error
var certDER []byte
switch sk := key.(type) {
case *rsa.PrivateKey:
pk := sk.Public()
tpl.SignatureAlgorithm = x509.SHA256WithRSA
certDER, err = x509.CreateCertificate(rand.Reader, &tpl, &tpl, pk, sk)
if err != nil {
return nil, &rtcerr.UnknownError{Err: err}
}
case *ecdsa.PrivateKey:
pk := sk.Public()
tpl.SignatureAlgorithm = x509.ECDSAWithSHA256
certDER, err = x509.CreateCertificate(rand.Reader, &tpl, &tpl, pk, sk)
if err != nil {
return nil, &rtcerr.UnknownError{Err: err}
}
default:
return nil, &rtcerr.NotSupportedError{Err: ErrPrivateKeyType}
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, &rtcerr.UnknownError{Err: err}
}
return &Certificate{
privateKey: key,
x509Cert: cert,
statsID: fmt.Sprintf("certificate-%d", time.Now().UnixNano()),
}, nil
}
// Equals determines if two certificates are identical by comparing both the
// secretKeys and x509Certificates.
func (c Certificate) Equals(cert Certificate) bool {
switch cSK := c.privateKey.(type) {
case *rsa.PrivateKey:
if oSK, ok := cert.privateKey.(*rsa.PrivateKey); ok {
if cSK.N.Cmp(oSK.N) != 0 {
return false
}
return c.x509Cert.Equal(cert.x509Cert)
}
return false
case *ecdsa.PrivateKey:
if oSK, ok := cert.privateKey.(*ecdsa.PrivateKey); ok {
if cSK.X.Cmp(oSK.X) != 0 || cSK.Y.Cmp(oSK.Y) != 0 {
return false
}
return c.x509Cert.Equal(cert.x509Cert)
}
return false
default:
return false
}
}
// Expires returns the timestamp after which this certificate is no longer valid.
func (c Certificate) Expires() time.Time {
if c.x509Cert == nil {
return time.Time{}
}
return c.x509Cert.NotAfter
}
// GetFingerprints returns the list of certificate fingerprints, one of which
// is computed with the digest algorithm used in the certificate signature.
func (c Certificate) GetFingerprints() ([]DTLSFingerprint, error) {
fingerprintAlgorithms := []crypto.Hash{crypto.SHA256}
res := make([]DTLSFingerprint, len(fingerprintAlgorithms))
i := 0
for _, algo := range fingerprintAlgorithms {
name, err := fingerprint.StringFromHash(algo)
if err != nil {
// nolint
return nil, fmt.Errorf("%w: %v", ErrFailedToGenerateCertificateFingerprint, err)
}
value, err := fingerprint.Fingerprint(c.x509Cert, algo)
if err != nil {
// nolint
return nil, fmt.Errorf("%w: %v", ErrFailedToGenerateCertificateFingerprint, err)
}
res[i] = DTLSFingerprint{
Algorithm: name,
Value: value,
}
}
return res[:i+1], nil
}
// GenerateCertificate causes the creation of an X.509 certificate and
// corresponding private key.
func GenerateCertificate(secretKey crypto.PrivateKey) (*Certificate, error) {
// Max random value, a 130-bits integer, i.e 2^130 - 1
maxBigInt := new(big.Int)
/* #nosec */
maxBigInt.Exp(big.NewInt(2), big.NewInt(130), nil).Sub(maxBigInt, big.NewInt(1))
/* #nosec */
serialNumber, err := rand.Int(rand.Reader, maxBigInt)
if err != nil {
return nil, &rtcerr.UnknownError{Err: err}
}
return NewCertificate(secretKey, x509.Certificate{
Issuer: pkix.Name{CommonName: generatedCertificateOrigin},
NotBefore: time.Now().AddDate(0, 0, -1),
NotAfter: time.Now().AddDate(0, 1, -1),
SerialNumber: serialNumber,
Version: 2,
Subject: pkix.Name{CommonName: generatedCertificateOrigin},
})
}
// CertificateFromX509 creates a new WebRTC Certificate from a given PrivateKey and Certificate
//
// This can be used if you want to share a certificate across multiple PeerConnections.
func CertificateFromX509(privateKey crypto.PrivateKey, certificate *x509.Certificate) Certificate {
return Certificate{privateKey, certificate, fmt.Sprintf("certificate-%d", time.Now().UnixNano())}
}
func (c Certificate) collectStats(report *statsReportCollector) error {
report.Collecting()
fingerPrintAlgo, err := c.GetFingerprints()
if err != nil {
return err
}
base64Certificate := base64.RawURLEncoding.EncodeToString(c.x509Cert.Raw)
stats := CertificateStats{
Timestamp: statsTimestampFrom(time.Now()),
Type: StatsTypeCertificate,
ID: c.statsID,
Fingerprint: fingerPrintAlgo[0].Value,
FingerprintAlgorithm: fingerPrintAlgo[0].Algorithm,
Base64Certificate: base64Certificate,
IssuerCertificateID: c.x509Cert.Issuer.String(),
}
report.Collect(stats.ID, stats)
return nil
}
// CertificateFromPEM creates a fresh certificate based on a string containing
// pem blocks fort the private key and x509 certificate.
func CertificateFromPEM(pems string) (*Certificate, error) { //nolint: cyclop
var cert *x509.Certificate
var privateKey crypto.PrivateKey
var block *pem.Block
more := []byte(pems)
for {
var err error
block, more = pem.Decode(more)
if block == nil {
break
}
// decode & parse the certificate
switch block.Type {
case "CERTIFICATE":
if cert != nil {
return nil, errCertificatePEMMultipleCert
}
cert, err = x509.ParseCertificate(block.Bytes)
// If parsing failed using block.Bytes, then parse the bytes as base64 and try again
if err != nil {
var n int
certBytes := make([]byte, base64.StdEncoding.DecodedLen(len(block.Bytes)))
n, err = base64.StdEncoding.Decode(certBytes, block.Bytes)
if err == nil {
cert, err = x509.ParseCertificate(certBytes[:n])
}
}
case "PRIVATE KEY":
if privateKey != nil {
return nil, errCertificatePEMMultiplePriv
}
privateKey, err = x509.ParsePKCS8PrivateKey(block.Bytes)
}
// Report errors from parsing either the private key or the certificate
if err != nil {
return nil, fmt.Errorf("failed to decode %s: %w", block.Type, err)
}
}
if cert == nil || privateKey == nil {
return nil, errCertificatePEMMissing
}
ret := CertificateFromX509(privateKey, cert)
return &ret, nil
}
// PEM returns the certificate encoded as two pem block: once for the X509
// certificate and the other for the private key.
func (c Certificate) PEM() (string, error) {
// First write the X509 certificate
var builder strings.Builder
err := pem.Encode(&builder, &pem.Block{Type: "CERTIFICATE", Bytes: c.x509Cert.Raw})
if err != nil {
return "", fmt.Errorf("failed to pem encode the X certificate: %w", err)
}
// Next write the private key
privBytes, err := x509.MarshalPKCS8PrivateKey(c.privateKey)
if err != nil {
return "", fmt.Errorf("failed to marshal private key: %w", err)
}
err = pem.Encode(&builder, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
if err != nil {
return "", fmt.Errorf("failed to encode private key: %w", err)
}
return builder.String(), nil
}

174
certificate_js_test.go Normal file
View File

@@ -0,0 +1,174 @@
//go:build js && wasm
// +build js,wasm
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestGenerateCertificateRSA(t *testing.T) {
sk, err := rsa.GenerateKey(rand.Reader, 2048)
assert.Nil(t, err)
skPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(sk),
})
cert, err := GenerateCertificate(sk)
assert.Nil(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.x509Cert.Raw,
})
_, err = tls.X509KeyPair(certPEM, skPEM)
assert.Nil(t, err)
}
func TestGenerateCertificateECDSA(t *testing.T) {
sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
skDER, err := x509.MarshalECPrivateKey(sk)
assert.Nil(t, err)
skPEM := pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: skDER,
})
cert, err := GenerateCertificate(sk)
assert.Nil(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.x509Cert.Raw,
})
_, err = tls.X509KeyPair(certPEM, skPEM)
assert.Nil(t, err)
}
func TestGenerateCertificateEqual(t *testing.T) {
sk1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
sk3, err := rsa.GenerateKey(rand.Reader, 2048)
assert.NoError(t, err)
cert1, err := GenerateCertificate(sk1)
assert.Nil(t, err)
sk2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
cert2, err := GenerateCertificate(sk2)
assert.Nil(t, err)
cert3, err := GenerateCertificate(sk3)
assert.NoError(t, err)
assert.True(t, cert1.Equals(*cert1))
assert.False(t, cert1.Equals(*cert2))
assert.True(t, cert3.Equals(*cert3))
}
func TestGenerateCertificateExpires(t *testing.T) {
sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
cert, err := GenerateCertificate(sk)
assert.Nil(t, err)
now := time.Now()
assert.False(t, cert.Expires().IsZero() || now.After(cert.Expires()))
x509Cert := CertificateFromX509(sk, &x509.Certificate{})
assert.NotNil(t, x509Cert)
assert.Contains(t, x509Cert.statsID, "certificate")
}
func TestPEM(t *testing.T) {
sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
cert, err := GenerateCertificate(sk)
assert.Nil(t, err)
pem, err := cert.PEM()
assert.Nil(t, err)
cert2, err := CertificateFromPEM(pem)
assert.Nil(t, err)
pem2, err := cert2.PEM()
assert.Nil(t, err)
assert.Equal(t, pem, pem2)
}
const (
certHeader = `!! This is a test certificate: Don't use it in production !!
You can create your own using openssl
` + "```sh" + `
openssl req -new -sha256 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` +
`-x509 -nodes -days 365 -out cert.pem -keyout cert.pem -subj "/CN=WebRTC"
openssl x509 -in cert.pem -noout -fingerprint -sha256
` + "```\n"
certPriv = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2XFaTNqFpTUqNtG9
A21MEe04JtsWVpUTDD8nI0KvchKhRANCAAS1nqME3jS5GFicwYfGDYaz7oSINwWm
X4BkfsSCxMrhr7mPtfxOi4Lxy/P3w6EvSSEU8t5E9ouKIWh5xPS9dYwu
-----END PRIVATE KEY-----
`
certCert = `-----BEGIN CERTIFICATE-----
MIIBljCCATugAwIBAgIUQa1sD+5HG43K+hCEVZLYxB68/hQwCgYIKoZIzj0EAwIw
IDEeMBwGA1UEAwwVc3dpdGNoLmV2YW4tYnJhc3MubmV0MB4XDTI0MDQyNDIwMjEy
MFoXDTI1MDQyNDIwMjEyMFowIDEeMBwGA1UEAwwVc3dpdGNoLmV2YW4tYnJhc3Mu
bmV0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtZ6jBN40uRhYnMGHxg2Gs+6E
iDcFpl+AZH7EgsTK4a+5j7X8TouC8cvz98OhL0khFPLeRPaLiiFoecT0vXWMLqNT
MFEwHQYDVR0OBBYEFGecfGnYqZFVgUApHGgX2kSIhUusMB8GA1UdIwQYMBaAFGec
fGnYqZFVgUApHGgX2kSIhUusMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwID
SQAwRgIhAJ3VWO8JZ7FEOJhxpUCeyOgl+G4vXSHtj9J9NRD3uGGZAiEAsTKGLOGE
9c6CtLDU9Ohf1c+Xj2Yi9H+srLZj1mrsnd4=
-----END CERTIFICATE-----
`
)
func TestOpensslCert(t *testing.T) {
// Check that CertificateFromPEM can parse certificates with the PRIVATE KEY before the CERTIFICATE block
_, err := CertificateFromPEM(certHeader + certPriv + certCert)
assert.Nil(t, err)
}
func TestEmpty(t *testing.T) {
cert, err := CertificateFromPEM("")
assert.Nil(t, cert)
assert.Equal(t, errCertificatePEMMissing, err)
}
func TestMultiCert(t *testing.T) {
cert, err := CertificateFromPEM(certHeader + certCert + certPriv + certCert)
assert.Nil(t, cert)
assert.Equal(t, errCertificatePEMMultipleCert, err)
}
func TestMultiPriv(t *testing.T) {
cert, err := CertificateFromPEM(certPriv + certHeader + certCert + certPriv)
assert.Nil(t, cert)
assert.Equal(t, errCertificatePEMMultiplePriv, err)
}

173
certificate_test.go Normal file
View File

@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package webrtc
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestGenerateCertificateRSA(t *testing.T) {
sk, err := rsa.GenerateKey(rand.Reader, 2048)
assert.Nil(t, err)
skPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(sk),
})
cert, err := GenerateCertificate(sk)
assert.Nil(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.x509Cert.Raw,
})
_, err = tls.X509KeyPair(certPEM, skPEM)
assert.Nil(t, err)
}
func TestGenerateCertificateECDSA(t *testing.T) {
sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
skDER, err := x509.MarshalECPrivateKey(sk)
assert.Nil(t, err)
skPEM := pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: skDER,
})
cert, err := GenerateCertificate(sk)
assert.Nil(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.x509Cert.Raw,
})
_, err = tls.X509KeyPair(certPEM, skPEM)
assert.Nil(t, err)
}
func TestGenerateCertificateEqual(t *testing.T) {
sk1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
sk3, err := rsa.GenerateKey(rand.Reader, 2048)
assert.NoError(t, err)
cert1, err := GenerateCertificate(sk1)
assert.Nil(t, err)
sk2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
cert2, err := GenerateCertificate(sk2)
assert.Nil(t, err)
cert3, err := GenerateCertificate(sk3)
assert.NoError(t, err)
assert.True(t, cert1.Equals(*cert1))
assert.False(t, cert1.Equals(*cert2))
assert.True(t, cert3.Equals(*cert3))
}
func TestGenerateCertificateExpires(t *testing.T) {
sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
cert, err := GenerateCertificate(sk)
assert.Nil(t, err)
now := time.Now()
assert.False(t, cert.Expires().IsZero() || now.After(cert.Expires()))
x509Cert := CertificateFromX509(sk, &x509.Certificate{})
assert.NotNil(t, x509Cert)
assert.Contains(t, x509Cert.statsID, "certificate")
}
func TestPEM(t *testing.T) {
sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
assert.Nil(t, err)
cert, err := GenerateCertificate(sk)
assert.Nil(t, err)
pem, err := cert.PEM()
assert.Nil(t, err)
cert2, err := CertificateFromPEM(pem)
assert.Nil(t, err)
pem2, err := cert2.PEM()
assert.Nil(t, err)
assert.Equal(t, pem, pem2)
}
const (
certHeader = `!! This is a test certificate: Don't use it in production !!
You can create your own using openssl
` + "```sh" + `
openssl req -new -sha256 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` +
`-x509 -nodes -days 365 -out cert.pem -keyout cert.pem -subj "/CN=WebRTC"
openssl x509 -in cert.pem -noout -fingerprint -sha256
` + "```\n"
certPriv = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2XFaTNqFpTUqNtG9
A21MEe04JtsWVpUTDD8nI0KvchKhRANCAAS1nqME3jS5GFicwYfGDYaz7oSINwWm
X4BkfsSCxMrhr7mPtfxOi4Lxy/P3w6EvSSEU8t5E9ouKIWh5xPS9dYwu
-----END PRIVATE KEY-----
`
certCert = `-----BEGIN CERTIFICATE-----
MIIBljCCATugAwIBAgIUQa1sD+5HG43K+hCEVZLYxB68/hQwCgYIKoZIzj0EAwIw
IDEeMBwGA1UEAwwVc3dpdGNoLmV2YW4tYnJhc3MubmV0MB4XDTI0MDQyNDIwMjEy
MFoXDTI1MDQyNDIwMjEyMFowIDEeMBwGA1UEAwwVc3dpdGNoLmV2YW4tYnJhc3Mu
bmV0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtZ6jBN40uRhYnMGHxg2Gs+6E
iDcFpl+AZH7EgsTK4a+5j7X8TouC8cvz98OhL0khFPLeRPaLiiFoecT0vXWMLqNT
MFEwHQYDVR0OBBYEFGecfGnYqZFVgUApHGgX2kSIhUusMB8GA1UdIwQYMBaAFGec
fGnYqZFVgUApHGgX2kSIhUusMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwID
SQAwRgIhAJ3VWO8JZ7FEOJhxpUCeyOgl+G4vXSHtj9J9NRD3uGGZAiEAsTKGLOGE
9c6CtLDU9Ohf1c+Xj2Yi9H+srLZj1mrsnd4=
-----END CERTIFICATE-----
`
)
func TestOpensslCert(t *testing.T) {
// Check that CertificateFromPEM can parse certificates with the PRIVATE KEY before the CERTIFICATE block
_, err := CertificateFromPEM(certHeader + certPriv + certCert)
assert.Nil(t, err)
}
func TestEmpty(t *testing.T) {
cert, err := CertificateFromPEM("")
assert.Nil(t, cert)
assert.Equal(t, errCertificatePEMMissing, err)
}
func TestMultiCert(t *testing.T) {
cert, err := CertificateFromPEM(certHeader + certCert + certPriv + certCert)
assert.Nil(t, cert)
assert.Equal(t, errCertificatePEMMultipleCert, err)
}
func TestMultiPriv(t *testing.T) {
cert, err := CertificateFromPEM(certPriv + certHeader + certCert + certPriv)
assert.Nil(t, cert)
assert.Equal(t, errCertificatePEMMultiplePriv, err)
}

22
codecov.yml Normal file
View File

@@ -0,0 +1,22 @@
#
# DO NOT EDIT THIS FILE
#
# It is automatically copied from https://github.com/pion/.goassets repository.
#
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
coverage:
status:
project:
default:
# Allow decreasing 2% of total coverage to avoid noise.
threshold: 2%
patch:
default:
target: 70%
only_pulls: true
ignore:
- "examples/*"
- "examples/**/*"

58
configuration.go Normal file
View File

@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package webrtc
// A Configuration defines how peer-to-peer communication via PeerConnection
// is established or re-established.
// Configurations may be set up once and reused across multiple connections.
// Configurations are treated as readonly. As long as they are unmodified,
// they are safe for concurrent use.
type Configuration struct {
// ICEServers defines a slice describing servers available to be used by
// ICE, such as STUN and TURN servers.
ICEServers []ICEServer `json:"iceServers,omitempty"`
// ICETransportPolicy indicates which candidates the ICEAgent is allowed
// to use.
ICETransportPolicy ICETransportPolicy `json:"iceTransportPolicy,omitempty"`
// BundlePolicy indicates which media-bundling policy to use when gathering
// ICE candidates.
BundlePolicy BundlePolicy `json:"bundlePolicy,omitempty"`
// RTCPMuxPolicy indicates which rtcp-mux policy to use when gathering ICE
// candidates.
RTCPMuxPolicy RTCPMuxPolicy `json:"rtcpMuxPolicy,omitempty"`
// PeerIdentity sets the target peer identity for the PeerConnection.
// The PeerConnection will not establish a connection to a remote peer
// unless it can be successfully authenticated with the provided name.
PeerIdentity string `json:"peerIdentity,omitempty"`
// Certificates describes a set of certificates that the PeerConnection
// uses to authenticate. Valid values for this parameter are created
// through calls to the GenerateCertificate function. Although any given
// DTLS connection will use only one certificate, this attribute allows the
// caller to provide multiple certificates that support different
// algorithms. The final certificate will be selected based on the DTLS
// handshake, which establishes which certificates are allowed. The
// PeerConnection implementation selects which of the certificates is
// used for a given connection; how certificates are selected is outside
// the scope of this specification. If this value is absent, then a default
// set of certificates is generated for each PeerConnection instance.
Certificates []Certificate `json:"certificates,omitempty"`
// ICECandidatePoolSize describes the size of the prefetched ICE pool.
ICECandidatePoolSize uint8 `json:"iceCandidatePoolSize,omitempty"`
// SDPSemantics controls the type of SDP offers accepted by and
// SDP answers generated by the PeerConnection.
SDPSemantics SDPSemantics `json:"sdpSemantics,omitempty"`
// AlwaysNegotiateDataChannels specifies whether the application prefers
// to always negotiate data channels in the initial SDP offer.
AlwaysNegotiateDataChannels bool `json:"alwaysNegotiateDataChannels,omitempty"`
}

28
configuration_common.go Normal file
View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import "strings"
// getICEServers side-steps the strict parsing mode of the ice package
// (as defined in https://tools.ietf.org/html/rfc7064) by copying and then
// stripping any erroneous queries from "stun(s):" URLs before parsing.
func (c Configuration) getICEServers() []ICEServer {
iceServers := append([]ICEServer{}, c.ICEServers...)
for iceServersIndex := range iceServers {
iceServers[iceServersIndex].URLs = append([]string{}, iceServers[iceServersIndex].URLs...)
for urlsIndex, rawURL := range iceServers[iceServersIndex].URLs {
if strings.HasPrefix(rawURL, "stun") {
// strip the query from "stun(s):" if present
parts := strings.Split(rawURL, "?")
rawURL = parts[0]
}
iceServers[iceServersIndex].URLs[urlsIndex] = rawURL
}
}
return iceServers
}

45
configuration_js.go Normal file
View File

@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package webrtc
// Configuration defines a set of parameters to configure how the
// peer-to-peer communication via PeerConnection is established or
// re-established.
type Configuration struct {
// ICEServers defines a slice describing servers available to be used by
// ICE, such as STUN and TURN servers.
ICEServers []ICEServer
// ICETransportPolicy indicates which candidates the ICEAgent is allowed
// to use.
ICETransportPolicy ICETransportPolicy
// BundlePolicy indicates which media-bundling policy to use when gathering
// ICE candidates.
BundlePolicy BundlePolicy
// RTCPMuxPolicy indicates which rtcp-mux policy to use when gathering ICE
// candidates.
RTCPMuxPolicy RTCPMuxPolicy
// PeerIdentity sets the target peer identity for the PeerConnection.
// The PeerConnection will not establish a connection to a remote peer
// unless it can be successfully authenticated with the provided name.
PeerIdentity string
// Certificates are not supported in the JavaScript/Wasm bindings.
// Certificates []Certificate
// ICECandidatePoolSize describes the size of the prefetched ICE pool.
ICECandidatePoolSize uint8
// AlwaysNegotiateDataChannels specifies whether the application prefers
// to always negotiate data channels in the initial SDP offer.
AlwaysNegotiateDataChannels bool
Certificates []Certificate `json:"certificates,omitempty"`
}

79
configuration_test.go Normal file
View File

@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfiguration_getICEServers(t *testing.T) {
t.Run("Success", func(t *testing.T) {
expectedServerStr := "stun:stun.l.google.com:19302"
cfg := Configuration{
ICEServers: []ICEServer{
{
URLs: []string{expectedServerStr},
},
},
}
parsedURLs := cfg.getICEServers()
assert.Equal(t, expectedServerStr, parsedURLs[0].URLs[0])
})
t.Run("Success", func(t *testing.T) {
// ignore the fact that stun URLs shouldn't have a query
serverStr := "stun:global.stun.twilio.com:3478?transport=udp"
expectedServerStr := "stun:global.stun.twilio.com:3478"
cfg := Configuration{
ICEServers: []ICEServer{
{
URLs: []string{serverStr},
},
},
}
parsedURLs := cfg.getICEServers()
assert.Equal(t, expectedServerStr, parsedURLs[0].URLs[0])
})
}
func TestConfigurationJSON(t *testing.T) {
config := `{
"iceServers": [{"urls": ["turn:turn.example.org"],
"username": "jch",
"credential": "topsecret"
}],
"iceTransportPolicy": "relay",
"bundlePolicy": "balanced",
"rtcpMuxPolicy": "require"
}`
conf := Configuration{
ICEServers: []ICEServer{
{
URLs: []string{"turn:turn.example.org"},
Username: "jch",
Credential: "topsecret",
},
},
ICETransportPolicy: ICETransportPolicyRelay,
BundlePolicy: BundlePolicyBalanced,
RTCPMuxPolicy: RTCPMuxPolicyRequire,
}
var conf2 Configuration
assert.NoError(t, json.Unmarshal([]byte(config), &conf2))
assert.Equal(t, conf, conf2)
j2, err := json.Marshal(conf2)
assert.NoError(t, err)
var conf3 Configuration
assert.NoError(t, json.Unmarshal(j2, &conf3))
assert.Equal(t, conf2, conf3)
}

70
constants.go Normal file
View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"math"
"github.com/pion/dtls/v3"
)
const (
// default as the standard ethernet MTU
// can be overwritten with SettingEngine.SetReceiveMTU().
receiveMTU = 1500
// simulcastProbeCount is the amount of RTP Packets
// that handleUndeclaredSSRC will read and try to dispatch from
// mid and rid values.
simulcastProbeCount = 10
// simulcastMaxProbeRoutines is how many active routines can be used to probe
// If the total amount of incoming SSRCes exceeds this new requests will be ignored.
simulcastMaxProbeRoutines = 25
// Default Max SCTP Message Size is the largest single DataChannel
// message we can send or accept. This default was chosen to match FireFox.
defaultMaxSCTPMessageSize = 1073741823
// If a DataChannel Max Message Size isn't declared by the Remote(max-message-size)
// this is the value we default to. This value was chosen because it was the behavior
// of Pion before max-message-size was implemented.
sctpMaxMessageSizeUnsetValue = math.MaxUint16
mediaSectionApplication = "application"
sdpAttributeRid = "rid"
sdpAttributeSimulcast = "simulcast"
outboundMTU = 1200
sctpOutboundMTU = 1191
rtpPayloadTypeBitmask = 0x7F
incomingUnhandledRTPSsrc = "Incoming unhandled RTP ssrc(%d), OnTrack will not be fired. %v"
useReadSimulcast = "Use ReadSimulcast(rid) instead of Read() when multiple tracks are present"
generatedCertificateOrigin = "WebRTC"
// AttributeRtxPayloadType is the interceptor attribute added when Read()
// returns an RTX packet containing the RTX stream payload type.
AttributeRtxPayloadType = "rtx_payload_type"
// AttributeRtxSsrc is the interceptor attribute added when Read()
// returns an RTX packet containing the RTX stream SSRC.
AttributeRtxSsrc = "rtx_ssrc"
// AttributeRtxSequenceNumber is the interceptor attribute added when
// Read() returns an RTX packet containing the RTX stream sequence number.
AttributeRtxSequenceNumber = "rtx_sequence_number"
)
func defaultSrtpProtectionProfiles() []dtls.SRTPProtectionProfile {
return []dtls.SRTPProtectionProfile{
dtls.SRTP_AEAD_AES_256_GCM,
dtls.SRTP_AEAD_AES_128_GCM,
dtls.SRTP_AES128_CM_HMAC_SHA1_80,
}
}

773
datachannel.go Normal file
View File

@@ -0,0 +1,773 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package webrtc
import (
"errors"
"fmt"
"io"
"sync"
"sync/atomic"
"time"
"github.com/pion/datachannel"
"github.com/pion/logging"
"github.com/pion/webrtc/v4/pkg/rtcerr"
)
var errSCTPNotEstablished = errors.New("SCTP not established")
// DataChannel represents a WebRTC DataChannel
// The DataChannel interface represents a network channel
// which can be used for bidirectional peer-to-peer transfers of arbitrary data.
type DataChannel struct {
mu sync.RWMutex
statsID string
label string
ordered bool
maxPacketLifeTime *uint16
maxRetransmits *uint16
protocol string
negotiated bool
id *uint16
readyState atomic.Value // DataChannelState
bufferedAmountLowThreshold uint64
detachCalled bool
readLoopActive chan struct{}
isGracefulClosed bool
// The binaryType represents attribute MUST, on getting, return the value to
// which it was last set. On setting, if the new value is either the string
// "blob" or the string "arraybuffer", then set the IDL attribute to this
// new value. Otherwise, throw a SyntaxError. When an DataChannel object
// is created, the binaryType attribute MUST be initialized to the string
// "blob". This attribute controls how binary data is exposed to scripts.
// binaryType string
onMessageHandler func(DataChannelMessage)
openHandlerOnce sync.Once
onOpenHandler func()
dialHandlerOnce sync.Once
onDialHandler func()
closeHandlerOnce sync.Once
onCloseHandler func()
onBufferedAmountLow func()
onErrorHandler func(error)
sctpTransport *SCTPTransport
dataChannel *datachannel.DataChannel
// A reference to the associated api object used by this datachannel
api *API
log logging.LeveledLogger
}
// NewDataChannel creates a new DataChannel.
// This constructor is part of the ORTC API. It is not
// meant to be used together with the basic WebRTC API.
func (api *API) NewDataChannel(transport *SCTPTransport, params *DataChannelParameters) (*DataChannel, error) {
d, err := api.newDataChannel(params, nil, api.settingEngine.LoggerFactory.NewLogger("ortc"))
if err != nil {
return nil, err
}
err = d.open(transport)
if err != nil {
return nil, err
}
return d, nil
}
// newDataChannel is an internal constructor for the data channel used to
// create the DataChannel object before the networking is set up.
func (api *API) newDataChannel(
params *DataChannelParameters,
sctpTransport *SCTPTransport,
log logging.LeveledLogger,
) (*DataChannel, error) {
// https://w3c.github.io/webrtc-pc/#peer-to-peer-data-api (Step #5)
if len(params.Label) > 65535 {
return nil, &rtcerr.TypeError{Err: ErrStringSizeLimit}
}
dataChannel := &DataChannel{
sctpTransport: sctpTransport,
statsID: fmt.Sprintf("DataChannel-%d", time.Now().UnixNano()),
label: params.Label,
protocol: params.Protocol,
negotiated: params.Negotiated,
id: params.ID,
ordered: params.Ordered,
maxPacketLifeTime: params.MaxPacketLifeTime,
maxRetransmits: params.MaxRetransmits,
api: api,
log: log,
}
dataChannel.setReadyState(DataChannelStateConnecting)
return dataChannel, nil
}
// open opens the datachannel over the sctp transport.
func (d *DataChannel) open(sctpTransport *SCTPTransport) error { //nolint:cyclop
association := sctpTransport.association()
if association == nil {
return errSCTPNotEstablished
}
d.mu.Lock()
if d.sctpTransport != nil { // already open
d.mu.Unlock()
return nil
}
d.sctpTransport = sctpTransport
var channelType datachannel.ChannelType
var reliabilityParameter uint32
switch {
case d.maxPacketLifeTime == nil && d.maxRetransmits == nil:
if d.ordered {
channelType = datachannel.ChannelTypeReliable
} else {
channelType = datachannel.ChannelTypeReliableUnordered
}
case d.maxRetransmits != nil:
reliabilityParameter = uint32(*d.maxRetransmits)
if d.ordered {
channelType = datachannel.ChannelTypePartialReliableRexmit
} else {
channelType = datachannel.ChannelTypePartialReliableRexmitUnordered
}
default:
reliabilityParameter = uint32(*d.maxPacketLifeTime)
if d.ordered {
channelType = datachannel.ChannelTypePartialReliableTimed
} else {
channelType = datachannel.ChannelTypePartialReliableTimedUnordered
}
}
cfg := &datachannel.Config{
ChannelType: channelType,
Priority: datachannel.ChannelPriorityNormal,
ReliabilityParameter: reliabilityParameter,
Label: d.label,
Protocol: d.protocol,
Negotiated: d.negotiated,
LoggerFactory: d.api.settingEngine.LoggerFactory,
}
if d.id == nil {
// avoid holding lock when generating ID, since id generation locks
d.mu.Unlock()
var dcID *uint16
err := d.sctpTransport.generateAndSetDataChannelID(d.sctpTransport.dtlsTransport.role(), &dcID)
if err != nil {
return err
}
d.mu.Lock()
d.id = dcID
}
dc, err := datachannel.Dial(association, *d.id, cfg)
if err != nil {
d.mu.Unlock()
return err
}
// bufferedAmountLowThreshold and onBufferedAmountLow might be set earlier
dc.SetBufferedAmountLowThreshold(d.bufferedAmountLowThreshold)
dc.OnBufferedAmountLow(d.onBufferedAmountLow)
d.mu.Unlock()
d.onDial()
d.handleOpen(dc, false, d.negotiated)
return nil
}
// Transport returns the SCTPTransport instance the DataChannel is sending over.
func (d *DataChannel) Transport() *SCTPTransport {
d.mu.RLock()
defer d.mu.RUnlock()
return d.sctpTransport
}
// After onOpen is complete check that the user called detach
// and provide an error message if the call was missed.
func (d *DataChannel) checkDetachAfterOpen() {
d.mu.RLock()
defer d.mu.RUnlock()
if d.api.settingEngine.detach.DataChannels && !d.detachCalled {
d.log.Warn("webrtc.DetachDataChannels() enabled but didn't Detach, call Detach from OnOpen")
}
}
// OnOpen sets an event handler which is invoked when
// the underlying data transport has been established (or re-established).
func (d *DataChannel) OnOpen(f func()) {
d.mu.Lock()
d.openHandlerOnce = sync.Once{}
d.onOpenHandler = f
d.mu.Unlock()
if d.ReadyState() == DataChannelStateOpen {
// If the data channel is already open, call the handler immediately.
go d.openHandlerOnce.Do(func() {
f()
d.checkDetachAfterOpen()
})
}
}
func (d *DataChannel) onOpen() {
d.mu.RLock()
handler := d.onOpenHandler
if d.isGracefulClosed {
d.mu.RUnlock()
return
}
d.mu.RUnlock()
if handler != nil {
go d.openHandlerOnce.Do(func() {
handler()
d.checkDetachAfterOpen()
})
}
}
// OnDial sets an event handler which is invoked when the
// peer has been dialed, but before said peer has responded.
func (d *DataChannel) OnDial(f func()) {
d.mu.Lock()
d.dialHandlerOnce = sync.Once{}
d.onDialHandler = f
d.mu.Unlock()
if d.ReadyState() == DataChannelStateOpen {
// If the data channel is already open, call the handler immediately.
go d.dialHandlerOnce.Do(f)
}
}
func (d *DataChannel) onDial() {
d.mu.RLock()
handler := d.onDialHandler
if d.isGracefulClosed {
d.mu.RUnlock()
return
}
d.mu.RUnlock()
if handler != nil {
go d.dialHandlerOnce.Do(handler)
}
}
// OnClose sets an event handler which is invoked when
// the underlying data transport has been closed.
// Note: Due to backwards compatibility, there is a chance that
// OnClose can be called, even if the GracefulClose is used.
// If this is the case for you, you can deregister OnClose
// prior to GracefulClose.
func (d *DataChannel) OnClose(f func()) {
d.mu.Lock()
d.closeHandlerOnce = sync.Once{}
d.onCloseHandler = f
d.mu.Unlock()
if d.ReadyState() == DataChannelStateClosed {
// If the data channel is already closed, call the handler immediately.
go d.closeHandlerOnce.Do(f)
}
}
func (d *DataChannel) onClose() {
d.mu.RLock()
handler := d.onCloseHandler
d.mu.RUnlock()
if handler != nil {
go d.closeHandlerOnce.Do(handler)
}
}
// OnMessage sets an event handler which is invoked on a binary
// message arrival over the sctp transport from a remote peer.
// OnMessage can currently receive messages up to 16384 bytes
// in size. Check out the detach API if you want to use larger
// message sizes. Note that browser support for larger messages
// is also limited.
func (d *DataChannel) OnMessage(f func(msg DataChannelMessage)) {
d.mu.Lock()
defer d.mu.Unlock()
d.onMessageHandler = f
}
func (d *DataChannel) onMessage(msg DataChannelMessage) {
d.mu.RLock()
handler := d.onMessageHandler
if d.isGracefulClosed {
d.mu.RUnlock()
return
}
d.mu.RUnlock()
if handler == nil {
return
}
handler(msg)
}
func (d *DataChannel) handleOpen(dc *datachannel.DataChannel, isRemote, isAlreadyNegotiated bool) {
d.mu.Lock()
if d.isGracefulClosed { // The channel was closed during the connecting state
d.mu.Unlock()
if err := dc.Close(); err != nil {
d.log.Errorf("Failed to close DataChannel that was closed during connecting state %v", err.Error())
}
d.onClose()
return
}
d.dataChannel = dc
bufferedAmountLowThreshold := d.bufferedAmountLowThreshold
onBufferedAmountLow := d.onBufferedAmountLow
d.mu.Unlock()
d.setReadyState(DataChannelStateOpen)
// Fire the OnOpen handler immediately not using pion/datachannel
// * detached datachannels have no read loop, the user needs to read and query themselves
// * remote datachannels should fire OnOpened. This isn't spec compliant, but we can't break behavior yet
// * already negotiated datachannels should fire OnOpened
if d.api.settingEngine.detach.DataChannels || isRemote || isAlreadyNegotiated {
// bufferedAmountLowThreshold and onBufferedAmountLow might be set earlier
d.dataChannel.SetBufferedAmountLowThreshold(bufferedAmountLowThreshold)
d.dataChannel.OnBufferedAmountLow(onBufferedAmountLow)
d.onOpen()
} else {
dc.OnOpen(func() {
d.onOpen()
})
}
d.mu.Lock()
defer d.mu.Unlock()
if d.isGracefulClosed {
return
}
if !d.api.settingEngine.detach.DataChannels {
d.readLoopActive = make(chan struct{})
go d.readLoop()
}
}
// OnError sets an event handler which is invoked when
// the underlying data transport cannot be read.
func (d *DataChannel) OnError(f func(err error)) {
d.mu.Lock()
defer d.mu.Unlock()
d.onErrorHandler = f
}
func (d *DataChannel) onError(err error) {
d.mu.RLock()
handler := d.onErrorHandler
if d.isGracefulClosed {
d.mu.RUnlock()
return
}
d.mu.RUnlock()
if handler != nil {
go handler(err)
}
}
func (d *DataChannel) readLoop() {
defer func() {
d.mu.Lock()
readLoopActive := d.readLoopActive
d.mu.Unlock()
defer close(readLoopActive)
}()
buffer := make([]byte, sctpMaxMessageSizeUnsetValue)
for {
n, isString, err := d.dataChannel.ReadDataChannel(buffer)
if err != nil {
if errors.Is(err, io.ErrShortBuffer) {
if int64(n) < int64(d.api.settingEngine.getSCTPMaxMessageSize()) {
buffer = append(buffer, make([]byte, len(buffer))...) // nolint
continue
}
d.log.Errorf(
"Incoming DataChannel message larger then Max Message size %v",
d.api.settingEngine.getSCTPMaxMessageSize(),
)
}
d.setReadyState(DataChannelStateClosed)
if !errors.Is(err, io.EOF) {
d.onError(err)
}
d.onClose()
return
}
d.onMessage(DataChannelMessage{
Data: append([]byte{}, buffer[:n]...),
IsString: isString,
})
}
}
// Send sends the binary message to the DataChannel peer.
func (d *DataChannel) Send(data []byte) error {
err := d.ensureOpen()
if err != nil {
return err
}
_, err = d.dataChannel.WriteDataChannel(data, false)
return err
}
// SendText sends the text message to the DataChannel peer.
func (d *DataChannel) SendText(s string) error {
err := d.ensureOpen()
if err != nil {
return err
}
_, err = d.dataChannel.WriteDataChannel([]byte(s), true)
return err
}
func (d *DataChannel) ensureOpen() error {
if d.ReadyState() != DataChannelStateOpen {
return io.ErrClosedPipe
}
return nil
}
// Detach allows you to detach the underlying datachannel.
// This provides an idiomatic API to work with
// (`io.ReadWriteCloser` with its `.Read()` and `.Write()` methods,
// as opposed to `.Send()` and `.OnMessage`),
// however it disables the OnMessage callback.
// Before calling Detach you have to enable this behavior by calling
// webrtc.DetachDataChannels(). Combining detached and normal data channels
// is not supported.
// Please refer to the data-channels-detach example and the
// pion/datachannel documentation for the correct way to handle the
// resulting DataChannel object.
func (d *DataChannel) Detach() (datachannel.ReadWriteCloser, error) {
return d.DetachWithDeadline()
}
// DetachWithDeadline allows you to detach the underlying datachannel.
// It is the same as Detach but returns a ReadWriteCloserDeadliner.
func (d *DataChannel) DetachWithDeadline() (datachannel.ReadWriteCloserDeadliner, error) {
d.mu.Lock()
if !d.api.settingEngine.detach.DataChannels {
d.mu.Unlock()
return nil, errDetachNotEnabled
}
if d.dataChannel == nil {
d.mu.Unlock()
return nil, errDetachBeforeOpened
}
d.detachCalled = true
dataChannel := d.dataChannel
d.mu.Unlock()
// Remove the reference from SCTPTransport so that the datachannel
// can be garbage collected on close
d.sctpTransport.lock.Lock()
n := len(d.sctpTransport.dataChannels)
j := 0
for i := range n {
if d == d.sctpTransport.dataChannels[i] {
continue
}
d.sctpTransport.dataChannels[j] = d.sctpTransport.dataChannels[i]
j++
}
for i := j; i < n; i++ {
d.sctpTransport.dataChannels[i] = nil
}
d.sctpTransport.dataChannels = d.sctpTransport.dataChannels[:j]
d.sctpTransport.lock.Unlock()
return dataChannel, nil
}
// Close Closes the DataChannel. It may be called regardless of whether
// the DataChannel object was created by this peer or the remote peer.
func (d *DataChannel) Close() error {
return d.close(false)
}
// GracefulClose Closes the DataChannel. It may be called regardless of whether
// the DataChannel object was created by this peer or the remote peer. It also waits
// for any goroutines it started to complete. This is only safe to call outside of
// DataChannel callbacks or if in a callback, in its own goroutine.
func (d *DataChannel) GracefulClose() error {
return d.close(true)
}
// Normally, close only stops writes from happening, so graceful=true
// will wait for reads to be finished based on underlying SCTP association
// closure or a SCTP reset stream from the other side. This is safe to call
// with graceful=true after tearing down a PeerConnection but not
// necessarily before. For example, if you used a vnet and dropped all packets
// right before closing the DataChannel, you'd need never see a reset stream.
func (d *DataChannel) close(shouldGracefullyClose bool) error {
d.mu.Lock()
d.isGracefulClosed = true
readLoopActive := d.readLoopActive
if shouldGracefullyClose && readLoopActive != nil {
defer func() {
<-readLoopActive
}()
}
haveSctpTransport := d.dataChannel != nil
d.mu.Unlock()
if d.ReadyState() == DataChannelStateClosed {
return nil
}
d.setReadyState(DataChannelStateClosing)
if !haveSctpTransport {
return nil
}
return d.dataChannel.Close()
}
// Label represents a label that can be used to distinguish this
// DataChannel object from other DataChannel objects. Scripts are
// allowed to create multiple DataChannel objects with the same label.
func (d *DataChannel) Label() string {
d.mu.RLock()
defer d.mu.RUnlock()
return d.label
}
// Ordered returns true if the DataChannel is ordered, and false if
// out-of-order delivery is allowed.
func (d *DataChannel) Ordered() bool {
d.mu.RLock()
defer d.mu.RUnlock()
return d.ordered
}
// MaxPacketLifeTime represents the length of the time window (msec) during
// which transmissions and retransmissions may occur in unreliable mode.
func (d *DataChannel) MaxPacketLifeTime() *uint16 {
d.mu.RLock()
defer d.mu.RUnlock()
return d.maxPacketLifeTime
}
// MaxRetransmits represents the maximum number of retransmissions that are
// attempted in unreliable mode.
func (d *DataChannel) MaxRetransmits() *uint16 {
d.mu.RLock()
defer d.mu.RUnlock()
return d.maxRetransmits
}
// Protocol represents the name of the sub-protocol used with this
// DataChannel.
func (d *DataChannel) Protocol() string {
d.mu.RLock()
defer d.mu.RUnlock()
return d.protocol
}
// Negotiated represents whether this DataChannel was negotiated by the
// application (true), or not (false).
func (d *DataChannel) Negotiated() bool {
d.mu.RLock()
defer d.mu.RUnlock()
return d.negotiated
}
// ID represents the ID for this DataChannel. The value is initially
// null, which is what will be returned if the ID was not provided at
// channel creation time, and the DTLS role of the SCTP transport has not
// yet been negotiated. Otherwise, it will return the ID that was either
// selected by the script or generated. After the ID is set to a non-null
// value, it will not change.
func (d *DataChannel) ID() *uint16 {
d.mu.RLock()
defer d.mu.RUnlock()
return d.id
}
// ReadyState represents the state of the DataChannel object.
func (d *DataChannel) ReadyState() DataChannelState {
if v, ok := d.readyState.Load().(DataChannelState); ok {
return v
}
return DataChannelState(0)
}
// BufferedAmount represents the number of bytes of application data
// (UTF-8 text and binary data) that have been queued using send(). Even
// though the data transmission can occur in parallel, the returned value
// MUST NOT be decreased before the current task yielded back to the event
// loop to prevent race conditions. The value does not include framing
// overhead incurred by the protocol, or buffering done by the operating
// system or network hardware. The value of BufferedAmount slot will only
// increase with each call to the send() method as long as the ReadyState is
// open; however, BufferedAmount does not reset to zero once the channel
// closes.
func (d *DataChannel) BufferedAmount() uint64 {
d.mu.RLock()
defer d.mu.RUnlock()
if d.dataChannel == nil {
return 0
}
return d.dataChannel.BufferedAmount()
}
// BufferedAmountLowThreshold represents the threshold at which the
// bufferedAmount is considered to be low. When the bufferedAmount decreases
// from above this threshold to equal or below it, the bufferedamountlow
// event fires. BufferedAmountLowThreshold is initially zero on each new
// DataChannel, but the application may change its value at any time.
// The threshold is set to 0 by default.
func (d *DataChannel) BufferedAmountLowThreshold() uint64 {
d.mu.RLock()
defer d.mu.RUnlock()
if d.dataChannel == nil {
return d.bufferedAmountLowThreshold
}
return d.dataChannel.BufferedAmountLowThreshold()
}
// SetBufferedAmountLowThreshold is used to update the threshold.
// See BufferedAmountLowThreshold().
func (d *DataChannel) SetBufferedAmountLowThreshold(th uint64) {
d.mu.Lock()
defer d.mu.Unlock()
d.bufferedAmountLowThreshold = th
if d.dataChannel != nil {
d.dataChannel.SetBufferedAmountLowThreshold(th)
}
}
// OnBufferedAmountLow sets an event handler which is invoked when
// the number of bytes of outgoing data becomes lower than or equal to the
// BufferedAmountLowThreshold.
func (d *DataChannel) OnBufferedAmountLow(f func()) {
d.mu.Lock()
defer d.mu.Unlock()
onBufferedAmountLow := d.makeBufferedAmountLowHandler(f)
d.onBufferedAmountLow = onBufferedAmountLow
if d.dataChannel != nil {
d.dataChannel.OnBufferedAmountLow(onBufferedAmountLow)
}
}
func (d *DataChannel) makeBufferedAmountLowHandler(f func()) func() {
return func() {
go func() {
if d.ReadyState() != DataChannelStateOpen {
return
}
f()
}()
}
}
func (d *DataChannel) getStatsID() string {
d.mu.Lock()
defer d.mu.Unlock()
return d.statsID
}
func (d *DataChannel) collectStats(collector *statsReportCollector) {
collector.Collecting()
d.mu.Lock()
defer d.mu.Unlock()
stats := DataChannelStats{
Timestamp: statsTimestampNow(),
Type: StatsTypeDataChannel,
ID: d.statsID,
Label: d.label,
Protocol: d.protocol,
// TransportID string `json:"transportId"`
State: d.ReadyState(),
}
if d.id != nil {
stats.DataChannelIdentifier = int32(*d.id)
}
if d.dataChannel != nil {
stats.MessagesSent = d.dataChannel.MessagesSent()
stats.BytesSent = d.dataChannel.BytesSent()
stats.MessagesReceived = d.dataChannel.MessagesReceived()
stats.BytesReceived = d.dataChannel.BytesReceived()
}
collector.Collect(stats.ID, stats)
}
func (d *DataChannel) setReadyState(r DataChannelState) {
d.readyState.Store(r)
}

979
datachannel_go_test.go Normal file
View File

@@ -0,0 +1,979 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package webrtc
import (
"context"
"crypto/rand"
"encoding/binary"
"io"
"math/big"
"regexp"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/pion/datachannel"
"github.com/pion/logging"
"github.com/pion/transport/v4/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDataChannel_EventHandlers(t *testing.T) {
to := test.TimeOut(time.Second * 20)
defer to.Stop()
report := test.CheckRoutines(t)
defer report()
api := NewAPI()
dc := &DataChannel{api: api}
onDialCalled := make(chan struct{})
onOpenCalled := make(chan struct{})
onMessageCalled := make(chan struct{})
// Verify that the noop case works
assert.NotPanics(t, func() { dc.onOpen() })
dc.OnDial(func() {
close(onDialCalled)
})
dc.OnOpen(func() {
close(onOpenCalled)
})
dc.OnMessage(func(DataChannelMessage) {
close(onMessageCalled)
})
// Verify that the set handlers are called
assert.NotPanics(t, func() { dc.onDial() })
assert.NotPanics(t, func() { dc.onOpen() })
assert.NotPanics(t, func() { dc.onMessage(DataChannelMessage{Data: []byte("o hai")}) })
// Wait for all handlers to be called
<-onDialCalled
<-onOpenCalled
<-onMessageCalled
}
func TestDataChannel_OnCloseImmediateAfterClosed(t *testing.T) {
dc := &DataChannel{}
dc.setReadyState(DataChannelStateClosed)
called := atomic.Int32{}
done := make(chan struct{})
dc.OnClose(func() {
if called.Add(1) == 1 {
close(done)
}
})
select {
case <-done:
case <-time.After(3 * time.Second):
assert.Fail(t, "OnClose did not fire immediately for closed DataChannel")
}
// Simulate additional close signaling and verify nonce prevents duplicate calls.
dc.onClose()
time.Sleep(50 * time.Millisecond)
assert.Equal(t, int32(1), called.Load())
}
func TestDataChannel_MessagesAreOrdered(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
api := NewAPI()
dc := &DataChannel{api: api}
maxVal := 512
out := make(chan int)
inner := func(msg DataChannelMessage) {
// randomly sleep
// math/rand a weak RNG, but this does not need to be secure. Ignore with #nosec
/* #nosec */
randInt, err := rand.Int(rand.Reader, big.NewInt(int64(maxVal)))
assert.NoError(t, err, "Failed to get random sleep duration")
time.Sleep(time.Duration(randInt.Int64()) * time.Microsecond)
s, _ := binary.Varint(msg.Data)
out <- int(s)
}
dc.OnMessage(func(p DataChannelMessage) {
inner(p)
})
go func() {
for i := 1; i <= maxVal; i++ {
buf := make([]byte, 8)
binary.PutVarint(buf, int64(i))
dc.onMessage(DataChannelMessage{Data: buf})
// Change the registered handler a couple of times to make sure
// that everything continues to work, we don't lose messages, etc.
if i%2 == 0 {
handler := func(msg DataChannelMessage) {
inner(msg)
}
dc.OnMessage(handler)
}
}
}()
values := make([]int, 0, maxVal)
for v := range out {
values = append(values, v)
if len(values) == maxVal {
close(out)
}
}
expected := make([]int, maxVal)
for i := 1; i <= maxVal; i++ {
expected[i-1] = i
}
assert.EqualValues(t, expected, values)
}
// Note(albrow): This test includes some features that aren't supported by the
// Wasm bindings (at least for now).
func TestDataChannelParamters_Go(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
t.Run("MaxPacketLifeTime exchange", func(t *testing.T) {
ordered := true
var maxPacketLifeTime uint16 = 3
options := &DataChannelInit{
Ordered: &ordered,
MaxPacketLifeTime: &maxPacketLifeTime,
}
offerPC, answerPC, dc, done := setUpDataChannelParametersTest(t, options)
// Check if parameters are correctly set
assert.True(t, dc.Ordered(), "Ordered should be set to true")
if assert.NotNil(t, dc.MaxPacketLifeTime(), "should not be nil") {
assert.Equal(t, maxPacketLifeTime, *dc.MaxPacketLifeTime(), "should match")
}
answerPC.OnDataChannel(func(d *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if d.Label() != expectedLabel {
return
}
// Check if parameters are correctly set
assert.True(t, d.ordered, "Ordered should be set to true")
if assert.NotNil(t, d.maxPacketLifeTime, "should not be nil") {
assert.Equal(t, maxPacketLifeTime, *d.maxPacketLifeTime, "should match")
}
done <- true
})
closeReliabilityParamTest(t, offerPC, answerPC, done)
})
t.Run("All other property methods", func(t *testing.T) {
id := uint16(123)
dc := &DataChannel{}
dc.id = &id
dc.label = "mylabel"
dc.protocol = "myprotocol"
dc.negotiated = true
assert.Equal(t, dc.id, dc.ID(), "should match")
assert.Equal(t, dc.label, dc.Label(), "should match")
assert.Equal(t, dc.protocol, dc.Protocol(), "should match")
assert.Equal(t, dc.negotiated, dc.Negotiated(), "should match")
assert.Equal(t, uint64(0), dc.BufferedAmount(), "should match")
dc.SetBufferedAmountLowThreshold(1500)
assert.Equal(t, uint64(1500), dc.BufferedAmountLowThreshold(), "should match")
})
}
func TestDataChannelBufferedAmount(t *testing.T) { //nolint:cyclop
t.Run("set before datachannel becomes open", func(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
var nOfferBufferedAmountLowCbs uint32
var offerBufferedAmountLowThreshold uint64 = 1500
var nAnswerBufferedAmountLowCbs uint32
var answerBufferedAmountLowThreshold uint64 = 1400
buf := make([]byte, 1000)
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
nPacketsToSend := int(10)
var nOfferReceived uint32
var nAnswerReceived uint32
done := make(chan bool)
answerPC.OnDataChannel(func(answerDC *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if answerDC.Label() != expectedLabel {
return
}
answerDC.OnOpen(func() {
assert.Equal(t, answerBufferedAmountLowThreshold, answerDC.BufferedAmountLowThreshold(), "value mismatch")
for range nPacketsToSend {
e := answerDC.Send(buf)
assert.NoError(t, e, "Failed to send string on data channel")
}
})
answerDC.OnMessage(func(DataChannelMessage) {
atomic.AddUint32(&nAnswerReceived, 1)
})
assert.True(t, answerDC.Ordered(), "Ordered should be set to true")
// The value is temporarily stored in the answerDC object
// until the answerDC gets opened
answerDC.SetBufferedAmountLowThreshold(answerBufferedAmountLowThreshold)
// The callback function is temporarily stored in the answerDC object
// until the answerDC gets opened
answerDC.OnBufferedAmountLow(func() {
atomic.AddUint32(&nAnswerBufferedAmountLowCbs, 1)
if atomic.LoadUint32(&nOfferBufferedAmountLowCbs) > 0 {
done <- true
}
})
})
offerDC, err := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, err, "Failed to create a PC pair for testing")
assert.True(t, offerDC.Ordered(), "Ordered should be set to true")
offerDC.OnOpen(func() {
assert.Equal(t, offerBufferedAmountLowThreshold, offerDC.BufferedAmountLowThreshold(), "value mismatch")
for range nPacketsToSend {
e := offerDC.Send(buf)
assert.NoError(t, e, "Failed to send string on data channel")
// assert.Equal(t, (i+1)*len(buf), int(offerDC.BufferedAmount()), "unexpected bufferedAmount")
}
})
offerDC.OnMessage(func(DataChannelMessage) {
atomic.AddUint32(&nOfferReceived, 1)
})
// The value is temporarily stored in the offerDC object
// until the offerDC gets opened
offerDC.SetBufferedAmountLowThreshold(offerBufferedAmountLowThreshold)
// The callback function is temporarily stored in the offerDC object
// until the offerDC gets opened
offerDC.OnBufferedAmountLow(func() {
atomic.AddUint32(&nOfferBufferedAmountLowCbs, 1)
if atomic.LoadUint32(&nAnswerBufferedAmountLowCbs) > 0 {
done <- true
}
})
err = signalPair(offerPC, answerPC)
assert.NoError(t, err, "Failed to signal our PC pair for testing")
closePair(t, offerPC, answerPC, done)
t.Logf("nOfferBufferedAmountLowCbs : %d", nOfferBufferedAmountLowCbs)
t.Logf("nAnswerBufferedAmountLowCbs: %d", nAnswerBufferedAmountLowCbs)
assert.True(t, nOfferBufferedAmountLowCbs > uint32(0), "callback should be made at least once")
assert.True(t, nAnswerBufferedAmountLowCbs > uint32(0), "callback should be made at least once")
})
t.Run("set after datachannel becomes open", func(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
var nCbs uint32
buf := make([]byte, 1000)
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
done := make(chan bool)
answerPC.OnDataChannel(func(dataChannel *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if dataChannel.Label() != expectedLabel {
return
}
var nPacketsReceived int
dataChannel.OnMessage(func(DataChannelMessage) {
nPacketsReceived++
if nPacketsReceived == 10 {
go func() {
time.Sleep(time.Second)
done <- true
}()
}
})
assert.True(t, dataChannel.Ordered(), "Ordered should be set to true")
})
dc, err := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, err)
assert.True(t, dc.Ordered(), "Ordered should be set to true")
dc.OnOpen(func() {
// The value should directly be passed to sctp
dc.SetBufferedAmountLowThreshold(1500)
// The callback function should directly be passed to sctp
dc.OnBufferedAmountLow(func() {
atomic.AddUint32(&nCbs, 1)
})
for range 10 {
assert.NoError(t, dc.Send(buf), "Failed to send string on data channel")
assert.Equal(t, uint64(1500), dc.BufferedAmountLowThreshold(), "value mismatch")
// assert.Equal(t, (i+1)*len(buf), int(dc.BufferedAmount()), "unexpected bufferedAmount")
}
})
dc.OnMessage(func(DataChannelMessage) {
})
assert.NoError(t, signalPair(offerPC, answerPC))
closePair(t, offerPC, answerPC, done)
assert.True(t, atomic.LoadUint32(&nCbs) > 0, "callback should be made at least once")
})
}
func TestEOF(t *testing.T) { //nolint:cyclop
t.Helper()
report := test.CheckRoutines(t)
defer report()
log := logging.NewDefaultLoggerFactory().NewLogger("test")
label := "test-channel"
testData := []byte("this is some test data")
t.Run("Detach", func(t *testing.T) {
// Use Detach data channels mode
s := SettingEngine{}
s.DetachDataChannels()
api := NewAPI(WithSettingEngine(s))
// Set up two peer connections.
config := Configuration{}
pca, err := api.NewPeerConnection(config)
assert.NoError(t, err)
pcb, err := api.NewPeerConnection(config)
assert.NoError(t, err)
defer closePairNow(t, pca, pcb)
var wg sync.WaitGroup
dcChan := make(chan datachannel.ReadWriteCloser)
pcb.OnDataChannel(func(dc *DataChannel) {
if dc.Label() != label {
return
}
log.Debug("OnDataChannel was called")
dc.OnOpen(func() {
detached, err2 := dc.Detach()
assert.NoError(t, err2, "Detach failed")
dcChan <- detached
})
})
wg.Add(1)
go func() {
defer wg.Done()
var msg []byte
log.Debug("Waiting for OnDataChannel")
dc := <-dcChan
log.Debug("data channel opened")
defer func() { assert.NoError(t, dc.Close(), "should succeed") }()
log.Debug("Waiting for ping...")
msg, err2 := io.ReadAll(dc)
log.Debugf("Received ping! \"%s\"", string(msg))
assert.NoError(t, err2)
assert.Equal(t, testData, msg)
}()
assert.NoError(t, signalPair(pca, pcb))
attached, err := pca.CreateDataChannel(label, nil)
assert.NoError(t, err)
log.Debug("Waiting for data channel to open")
open := make(chan struct{})
attached.OnOpen(func() {
open <- struct{}{}
})
<-open
log.Debug("data channel opened")
var dc io.ReadWriteCloser
dc, err = attached.Detach()
assert.NoError(t, err)
wg.Add(1)
go func() {
defer wg.Done()
log.Debug("Sending ping...")
_, err = dc.Write(testData)
assert.NoError(t, err)
log.Debug("Sent ping")
assert.NoError(t, dc.Close(), "should succeed")
log.Debug("Wating for EOF")
ret, err2 := io.ReadAll(dc)
assert.Nil(t, err2, "should succeed")
assert.Equal(t, 0, len(ret), "should be empty")
}()
wg.Wait()
})
t.Run("No detach", func(t *testing.T) {
lim := test.TimeOut(time.Second * 5)
defer lim.Stop()
// Set up two peer connections.
config := Configuration{}
pca, err := NewPeerConnection(config)
assert.NoError(t, err)
pcb, err := NewPeerConnection(config)
assert.NoError(t, err)
defer closePairNow(t, pca, pcb)
var dca, dcb *DataChannel
dcaClosedCh := make(chan struct{})
dcbClosedCh := make(chan struct{})
pcb.OnDataChannel(func(dc *DataChannel) {
if dc.Label() != label {
return
}
log.Debugf("pcb: new datachannel: %s", dc.Label())
dcb = dc
// Register channel opening handling
dcb.OnOpen(func() {
log.Debug("pcb: datachannel opened")
})
dcb.OnClose(func() {
// (2)
log.Debug("pcb: data channel closed")
close(dcbClosedCh)
})
// Register the OnMessage to handle incoming messages
log.Debug("pcb: registering onMessage callback")
dcb.OnMessage(func(dcMsg DataChannelMessage) {
log.Debugf("pcb: received ping: %s", string(dcMsg.Data))
assert.Equal(t, testData, dcMsg.Data)
})
})
dca, err = pca.CreateDataChannel(label, nil)
assert.NoError(t, err)
dca.OnOpen(func() {
log.Debug("pca: data channel opened")
log.Debugf("pca: sending \"%s\"", string(testData))
assert.NoError(t, dca.Send(testData))
log.Debug("pca: sent ping")
assert.NoError(t, dca.Close(), "should succeed") // <-- dca closes
})
dca.OnClose(func() {
// (1)
log.Debug("pca: data channel closed")
close(dcaClosedCh)
})
// Register the OnMessage to handle incoming messages
log.Debug("pca: registering onMessage callback")
dca.OnMessage(func(dcMsg DataChannelMessage) {
log.Debugf("pca: received pong: %s", string(dcMsg.Data))
assert.Equal(t, testData, dcMsg.Data)
})
assert.NoError(t, signalPair(pca, pcb))
// When dca closes the channel,
// (1) dca.Onclose() will fire immediately, then
// (2) dcb.OnClose will also fire
<-dcaClosedCh // (1)
<-dcbClosedCh // (2)
})
}
func TestDataChannel_BundleOnlyPortZero(t *testing.T) {
offerPC, err := NewPeerConnection(Configuration{})
require.NoError(t, err)
defer func() { require.NoError(t, offerPC.Close()) }()
answerPC, err := NewPeerConnection(Configuration{})
require.NoError(t, err)
defer func() { require.NoError(t, answerPC.Close()) }()
_, err = offerPC.AddTransceiverFromKind(RTPCodecTypeAudio)
require.NoError(t, err)
dataChannel, err := offerPC.CreateDataChannel(expectedLabel, nil)
require.NoError(t, err)
answerPC.OnDataChannel(func(*DataChannel) {})
offer, err := offerPC.CreateOffer(nil)
require.NoError(t, err)
offerGatheringComplete := GatheringCompletePromise(offerPC)
require.NoError(t, offerPC.SetLocalDescription(offer))
<-offerGatheringComplete
const applicationMLine = "m=application 9 UDP/DTLS/SCTP webrtc-datachannel"
require.Contains(t, offerPC.LocalDescription().SDP, applicationMLine)
bundleOnlyOfferSDP := strings.Replace(
offerPC.LocalDescription().SDP,
applicationMLine,
"m=application 0 UDP/DTLS/SCTP webrtc-datachannel\r\na=bundle-only",
1,
)
require.NoError(t, answerPC.SetRemoteDescription(SessionDescription{
Type: SDPTypeOffer,
SDP: bundleOnlyOfferSDP,
}))
answer, err := answerPC.CreateAnswer(nil)
require.NoError(t, err)
answerGatheringComplete := GatheringCompletePromise(answerPC)
require.NoError(t, answerPC.SetLocalDescription(answer))
<-answerGatheringComplete
require.NoError(t, offerPC.SetRemoteDescription(*answerPC.LocalDescription()))
require.Eventually(t, func() bool {
return dataChannel.ReadyState() == DataChannelStateOpen
}, 5*time.Second, 10*time.Millisecond)
}
// Assert that a Session Description that doesn't follow
// draft-ietf-mmusic-sctp-sdp is still accepted.
func TestDataChannel_NonStandardSessionDescription(t *testing.T) {
to := test.TimeOut(time.Second * 20)
defer to.Stop()
report := test.CheckRoutines(t)
defer report()
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
_, err = offerPC.CreateDataChannel("foo", nil)
assert.NoError(t, err)
onDataChannelCalled := make(chan struct{})
answerPC.OnDataChannel(func(_ *DataChannel) {
close(onDataChannelCalled)
})
offer, err := offerPC.CreateOffer(nil)
assert.NoError(t, err)
offerGatheringComplete := GatheringCompletePromise(offerPC)
assert.NoError(t, offerPC.SetLocalDescription(offer))
<-offerGatheringComplete
offer = *offerPC.LocalDescription()
// Replace with old values
const (
oldApplication = "m=application 63743 DTLS/SCTP 5000\r"
oldAttribute = "a=sctpmap:5000 webrtc-datachannel 256\r"
)
offer.SDP = regexp.MustCompile(`m=application (.*?)\r`).ReplaceAllString(offer.SDP, oldApplication)
offer.SDP = regexp.MustCompile(`a=sctp-port(.*?)\r`).ReplaceAllString(offer.SDP, oldAttribute)
// Assert that replace worked
assert.True(t, strings.Contains(offer.SDP, oldApplication))
assert.True(t, strings.Contains(offer.SDP, oldAttribute))
assert.NoError(t, answerPC.SetRemoteDescription(offer))
answer, err := answerPC.CreateAnswer(nil)
assert.NoError(t, err)
answerGatheringComplete := GatheringCompletePromise(answerPC)
assert.NoError(t, answerPC.SetLocalDescription(answer))
<-answerGatheringComplete
assert.NoError(t, offerPC.SetRemoteDescription(*answerPC.LocalDescription()))
<-onDataChannelCalled
closePairNow(t, offerPC, answerPC)
}
func TestDataChannel_Dial(t *testing.T) {
t.Run("handler should be called once, by dialing peer only", func(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
dialCalls := make(chan bool, 2)
wg := new(sync.WaitGroup)
wg.Add(2)
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
answerPC.OnDataChannel(func(d *DataChannel) {
if d.Label() != expectedLabel {
return
}
d.OnDial(func() {
// only dialing side should fire OnDial
assert.Fail(t, "answering side should not call on dial")
})
d.OnOpen(wg.Done)
})
d, err := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, err)
d.OnDial(func() {
dialCalls <- true
wg.Done()
})
assert.NoError(t, signalPair(offerPC, answerPC))
wg.Wait()
closePairNow(t, offerPC, answerPC)
assert.Len(t, dialCalls, 1)
})
t.Run("handler should be called immediately if already dialed", func(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
done := make(chan bool)
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
// Accept incoming data channels without closing them; without this the
// default handler would close the channel before the offer side opens it.
answerPC.OnDataChannel(func(_ *DataChannel) {})
d, err := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, err)
d.OnOpen(func() {
// when the offer DC has been opened, its guaranteed to have dialed since it has
// received a response to said dial. this test represents an unrealistic usage,
// but its the best way to guarantee we "missed" the dial event and still invoke
// the handler.
d.OnDial(func() {
done <- true
})
})
assert.NoError(t, signalPair(offerPC, answerPC))
closePair(t, offerPC, answerPC, done)
})
}
func TestDetachRemovesDatachannelReference(t *testing.T) {
// Use Detach data channels mode
s := SettingEngine{}
s.DetachDataChannels()
api := NewAPI(WithSettingEngine(s))
// Set up two peer connections.
config := Configuration{}
pca, err := api.NewPeerConnection(config)
assert.NoError(t, err)
pcb, err := api.NewPeerConnection(config)
assert.NoError(t, err)
defer closePairNow(t, pca, pcb)
dcChan := make(chan *DataChannel, 1)
pcb.OnDataChannel(func(d *DataChannel) {
d.OnOpen(func() {
_, detachErr := d.Detach()
assert.NoError(t, detachErr)
dcChan <- d
})
})
assert.NoError(t, signalPair(pca, pcb))
attached, err := pca.CreateDataChannel("", nil)
assert.NoError(t, err)
open := make(chan struct{}, 1)
attached.OnOpen(func() {
open <- struct{}{}
})
<-open
d := <-dcChan
d.sctpTransport.lock.RLock()
defer d.sctpTransport.lock.RUnlock()
for _, dc := range d.sctpTransport.dataChannels[:cap(d.sctpTransport.dataChannels)] {
assert.NotEqual(t, dc, d, "expected sctpTransport to drop reference to datachannel")
}
}
func TestDataChannelClose(t *testing.T) {
// Test if onClose is fired for self and remote after Close is called
t.Run("close open channels", func(t *testing.T) {
options := &DataChannelInit{}
offerPC, answerPC, dc, done := setUpDataChannelParametersTest(t, options)
answerPC.OnDataChannel(func(dataChannel *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if dataChannel.Label() != expectedLabel {
return
}
dataChannel.OnOpen(func() {
assert.NoError(t, dataChannel.Close())
})
dataChannel.OnClose(func() {
done <- true
})
})
dc.OnClose(func() {
done <- true
})
assert.NoError(t, signalPair(offerPC, answerPC))
// Offer and Answer OnClose
<-done
<-done
assert.NoError(t, offerPC.Close())
assert.NoError(t, answerPC.Close())
})
// Test if OnClose is fired for self and remote after Close is called on non-established channel
// https://github.com/pion/webrtc/issues/2659
t.Run("Close connecting channels", func(t *testing.T) {
options := &DataChannelInit{}
offerPC, answerPC, dc, done := setUpDataChannelParametersTest(t, options)
answerPC.OnDataChannel(func(dataChannel *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if dataChannel.Label() != expectedLabel {
return
}
dataChannel.OnOpen(func() {
assert.Fail(t, "OnOpen must not be fired after we call Close")
})
dataChannel.OnClose(func() {
done <- true
})
assert.NoError(t, dataChannel.Close())
})
dc.OnClose(func() {
done <- true
})
assert.NoError(t, signalPair(offerPC, answerPC))
// Offer and Answer OnClose
<-done
<-done
assert.NoError(t, offerPC.Close())
assert.NoError(t, answerPC.Close())
})
}
func TestDataChannel_DetachErrors(t *testing.T) {
t.Run("error errDetachNotEnabled", func(t *testing.T) {
s := SettingEngine{}
offer, answer, err := NewAPI(WithSettingEngine(s)).newPair(Configuration{})
assert.NoError(t, err)
dc, err := offer.CreateDataChannel("data", nil)
assert.NoError(t, err)
_, err = dc.Detach()
assert.ErrorIs(t, err, errDetachNotEnabled)
assert.NoError(t, offer.Close())
assert.NoError(t, answer.Close())
})
t.Run("error errDetachBeforeOpened", func(t *testing.T) {
s := SettingEngine{}
s.DetachDataChannels()
offer, answer, err := NewAPI(WithSettingEngine(s)).newPair(Configuration{})
assert.NoError(t, err)
dc, err := offer.CreateDataChannel("data", nil)
assert.NoError(t, err)
_, err = dc.Detach()
assert.ErrorIs(t, err, errDetachBeforeOpened)
assert.NoError(t, offer.Close())
assert.NoError(t, answer.Close())
})
}
func TestDataChannelMessageSize(t *testing.T) {
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
dc, err := offerPC.CreateDataChannel("", nil)
assert.NoError(t, err)
answerDataChannelMessages := make(chan []byte)
answerPC.OnDataChannel(func(d *DataChannel) {
d.OnMessage(func(m DataChannelMessage) {
answerDataChannelMessages <- m.Data
})
})
assert.NoError(t, signalPair(offerPC, answerPC))
messagesSent, messagesSentCancel := context.WithCancel(context.Background())
dc.OnOpen(func() {
for i := 0; i <= 10; i++ {
outboundMessage := make([]byte, sctpMaxMessageSizeUnsetValue*i)
_, err := rand.Read(outboundMessage)
assert.NoError(t, err)
assert.NoError(t, dc.Send(outboundMessage))
inboundMessage := <-answerDataChannelMessages
assert.Equal(t, outboundMessage, inboundMessage)
}
messagesSentCancel()
})
<-messagesSent.Done()
closePairNow(t, offerPC, answerPC)
}
func TestOnBufferedAmountLowDeadlock(t *testing.T) {
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
offerDataChannel, err := offerPC.CreateDataChannel("", nil)
assert.NoError(t, err)
assert.NoError(t, signalPair(offerPC, answerPC))
gotAllMessages, gotAllMessagesCancel := context.WithCancel(context.Background())
offerDataChannel.OnOpen(func() {
for {
select {
case <-gotAllMessages.Done():
return
case <-time.After(5 * time.Millisecond):
assert.NoError(t, offerDataChannel.Send([]byte{0xBE, 0xEF}))
}
}
})
answerPC.OnDataChannel(func(dataChannel *DataChannel) {
dataChannel.SetBufferedAmountLowThreshold(1)
var onBufferedAmountLowFired atomic.Bool
dataChannel.OnBufferedAmountLow(func() {
onBufferedAmountLowFired.Store(true)
<-gotAllMessages.Done()
})
var onMessageCount uint32
dataChannel.OnMessage(func(msg DataChannelMessage) {
if onBufferedAmountLowFired.Load() && atomic.AddUint32(&onMessageCount, 1) == 10 {
gotAllMessagesCancel()
}
})
})
<-gotAllMessages.Done()
closePairNow(t, offerPC, answerPC)
}
func TestOnBufferedAmountLowRespectsReadyState(t *testing.T) {
t.Run("fires when open", func(t *testing.T) {
dc := &DataChannel{}
dc.setReadyState(DataChannelStateOpen)
called := make(chan struct{}, 1)
dc.OnBufferedAmountLow(func() {
called <- struct{}{}
})
dc.mu.RLock()
handler := dc.onBufferedAmountLow
dc.mu.RUnlock()
handler()
select {
case <-called:
case <-time.After(time.Second):
assert.Fail(t, "expected OnBufferedAmountLow to fire when open")
}
})
t.Run("skips when not open", func(t *testing.T) {
dc := &DataChannel{}
dc.setReadyState(DataChannelStateClosing)
called := make(chan struct{}, 1)
dc.OnBufferedAmountLow(func() {
called <- struct{}{}
})
dc.mu.RLock()
handler := dc.onBufferedAmountLow
dc.mu.RUnlock()
handler()
select {
case <-called:
assert.Fail(t, "expected OnBufferedAmountLow to be ignored when not open")
case <-time.After(50 * time.Millisecond):
}
})
}

419
datachannel_js.go Normal file
View File

@@ -0,0 +1,419 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package webrtc
import (
"errors"
"fmt"
"syscall/js"
"github.com/pion/datachannel"
)
const dataChannelBufferSize = 16384 // Lowest common denominator among browsers
// DataChannel represents a WebRTC DataChannel
// The DataChannel interface represents a network channel
// which can be used for bidirectional peer-to-peer transfers of arbitrary data
type DataChannel struct {
// Pointer to the underlying JavaScript RTCPeerConnection object.
underlying js.Value
// Keep track of handlers/callbacks so we can call Release as required by the
// syscall/js API. Initially nil.
onOpenHandler *js.Func
onCloseHandler *js.Func
onClosingHandler *js.Func
onMessageHandler *js.Func
onBufferedAmountLow *js.Func
onErrorHandler *js.Func
// onCloseFunc retains the user-provided OnClose callback so it can still be
// invoked from a self-releasing wrapper installed by Close().
onCloseFunc func()
// closeWrapperInstalled guards the self-releasing onclose wrapper in Close()
// so repeated Close calls don't override the first wrapper before it fires.
closeWrapperInstalled bool
// A reference to the associated api object used by this datachannel
api *API
}
// JSValue returns the underlying RTCDataChannel
func (d *DataChannel) JSValue() js.Value {
return d.underlying
}
// OnOpen sets an event handler which is invoked when
// the underlying data transport has been established (or re-established).
func (d *DataChannel) OnOpen(f func()) {
if d.onOpenHandler != nil {
oldHandler := d.onOpenHandler
defer oldHandler.Release()
}
onOpenHandler := js.FuncOf(func(this js.Value, args []js.Value) any {
go f()
return js.Undefined()
})
d.onOpenHandler = &onOpenHandler
d.underlying.Set("onopen", onOpenHandler)
}
// OnClose sets an event handler which is invoked when
// the underlying data transport has been closed.
func (d *DataChannel) OnClose(f func()) {
if d.onCloseHandler != nil {
oldHandler := d.onCloseHandler
defer oldHandler.Release()
}
d.onCloseFunc = f
onCloseHandler := js.FuncOf(func(this js.Value, args []js.Value) any {
go f()
return js.Undefined()
})
d.onCloseHandler = &onCloseHandler
d.underlying.Set("onclose", onCloseHandler)
}
// FYI `OnClosing` is not implemented in the non-JS version of Pion.
func (d *DataChannel) OnClosing(f func()) {
if d.onClosingHandler != nil {
oldHandler := d.onClosingHandler
defer oldHandler.Release()
}
onClosingHandler := js.FuncOf(func(this js.Value, args []js.Value) any {
go f()
return js.Undefined()
})
d.onClosingHandler = &onClosingHandler
d.underlying.Set("onclosing", onClosingHandler)
}
func (d *DataChannel) OnError(f func(err error)) {
if d.onErrorHandler != nil {
oldHandler := d.onErrorHandler
defer oldHandler.Release()
}
onErrorHandler := js.FuncOf(func(this js.Value, args []js.Value) any {
event := args[0]
errorObj := event.Get("error")
// FYI RTCError has some extra properties, e.g. `errorDetail`:
// https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/error_event
errorMessage := errorObj.Get("message").String()
go f(errors.New(errorMessage))
return js.Undefined()
})
d.onErrorHandler = &onErrorHandler
d.underlying.Set("onerror", onErrorHandler)
}
// OnMessage sets an event handler which is invoked on a binary message arrival
// from a remote peer. Note that browsers may place limitations on message size.
func (d *DataChannel) OnMessage(f func(msg DataChannelMessage)) {
if d.onMessageHandler != nil {
oldHandler := d.onMessageHandler
defer oldHandler.Release()
}
onMessageHandler := js.FuncOf(func(this js.Value, args []js.Value) any {
// pion/webrtc/projects/15
data := args[0].Get("data")
go func() {
// valueToDataChannelMessage may block when handling 'Blob' data
// so we need to call it from a new routine. See:
// https://pkg.go.dev/syscall/js#FuncOf
msg := valueToDataChannelMessage(data)
f(msg)
}()
return js.Undefined()
})
d.onMessageHandler = &onMessageHandler
d.underlying.Set("onmessage", onMessageHandler)
}
// Send sends the binary message to the DataChannel peer
func (d *DataChannel) Send(data []byte) (err error) {
defer func() {
if e := recover(); e != nil {
err = recoveryToError(e)
}
}()
array := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(array, data)
d.underlying.Call("send", array)
return nil
}
// SendText sends the text message to the DataChannel peer
func (d *DataChannel) SendText(s string) (err error) {
defer func() {
if e := recover(); e != nil {
err = recoveryToError(e)
}
}()
d.underlying.Call("send", s)
return nil
}
// Detach allows you to detach the underlying datachannel. This provides
// an idiomatic API to work with, however it disables the OnMessage callback.
// Before calling Detach you have to enable this behavior by calling
// webrtc.DetachDataChannels(). Combining detached and normal data channels
// is not supported.
// Please refer to the data-channels-detach example and the
// pion/datachannel documentation for the correct way to handle the
// resulting DataChannel object.
func (d *DataChannel) Detach() (datachannel.ReadWriteCloser, error) {
if !d.api.settingEngine.detach.DataChannels {
return nil, fmt.Errorf("enable detaching by calling webrtc.DetachDataChannels()")
}
detached := newDetachedDataChannel(d)
return detached, nil
}
// Close Closes the DataChannel. It may be called regardless of whether
// the DataChannel object was created by this peer or the remote peer.
func (d *DataChannel) Close() (err error) {
defer func() {
if e := recover(); e != nil {
err = recoveryToError(e)
}
}()
// Nullify and release handlers that should not fire after Close is called.
if d.onOpenHandler != nil {
d.underlying.Set("onopen", js.Null())
d.onOpenHandler.Release()
d.onOpenHandler = nil
}
if d.onClosingHandler != nil {
d.underlying.Set("onclosing", js.Null())
d.onClosingHandler.Release()
d.onClosingHandler = nil
}
if d.onMessageHandler != nil {
d.underlying.Set("onmessage", js.Null())
d.onMessageHandler.Release()
d.onMessageHandler = nil
}
if d.onBufferedAmountLow != nil {
d.underlying.Set("onbufferedamountlow", js.Null())
d.onBufferedAmountLow.Release()
d.onBufferedAmountLow = nil
}
// Defer nullifying onclose and onerror until the channel has fully closed.
// An error event may still fire during the close handshake (between calling
// close() and the onclose event), and the user-provided OnClose callback
// must still be invoked. Install the wrapper only once so repeated Close()
// calls don't override the original wrapper before it fires.
if !d.closeWrapperInstalled {
oldCloseHandler := d.onCloseHandler
oldErrorHandler := d.onErrorHandler
closeFunc := d.onCloseFunc
var wrapperHandler js.Func
wrapperHandler = js.FuncOf(func(this js.Value, args []js.Value) any {
if closeFunc != nil {
go closeFunc()
}
d.underlying.Set("onclose", js.Null())
wrapperHandler.Release()
if oldErrorHandler != nil {
d.underlying.Set("onerror", js.Null())
oldErrorHandler.Release()
}
d.closeWrapperInstalled = false
return js.Undefined()
})
d.underlying.Set("onclose", wrapperHandler)
d.closeWrapperInstalled = true
if oldCloseHandler != nil {
oldCloseHandler.Release()
}
}
d.onCloseHandler = nil
d.onCloseFunc = nil
d.onErrorHandler = nil
d.underlying.Call("close")
return nil
}
// Label represents a label that can be used to distinguish this
// DataChannel object from other DataChannel objects. Scripts are
// allowed to create multiple DataChannel objects with the same label.
func (d *DataChannel) Label() string {
return d.underlying.Get("label").String()
}
// Ordered represents if the DataChannel is ordered, and false if
// out-of-order delivery is allowed.
func (d *DataChannel) Ordered() bool {
ordered := d.underlying.Get("ordered")
if ordered.IsUndefined() {
return true // default is true
}
return ordered.Bool()
}
// MaxPacketLifeTime represents the length of the time window (msec) during
// which transmissions and retransmissions may occur in unreliable mode.
func (d *DataChannel) MaxPacketLifeTime() *uint16 {
if !d.underlying.Get("maxPacketLifeTime").IsUndefined() {
return valueToUint16Pointer(d.underlying.Get("maxPacketLifeTime"))
}
// See https://bugs.chromium.org/p/chromium/issues/detail?id=696681
// Chrome calls this "maxRetransmitTime"
return valueToUint16Pointer(d.underlying.Get("maxRetransmitTime"))
}
// MaxRetransmits represents the maximum number of retransmissions that are
// attempted in unreliable mode.
func (d *DataChannel) MaxRetransmits() *uint16 {
return valueToUint16Pointer(d.underlying.Get("maxRetransmits"))
}
// Protocol represents the name of the sub-protocol used with this
// DataChannel.
func (d *DataChannel) Protocol() string {
return d.underlying.Get("protocol").String()
}
// Negotiated represents whether this DataChannel was negotiated by the
// application (true), or not (false).
func (d *DataChannel) Negotiated() bool {
return d.underlying.Get("negotiated").Bool()
}
// ID represents the ID for this DataChannel. The value is initially
// null, which is what will be returned if the ID was not provided at
// channel creation time. Otherwise, it will return the ID that was either
// selected by the script or generated. After the ID is set to a non-null
// value, it will not change.
func (d *DataChannel) ID() *uint16 {
return valueToUint16Pointer(d.underlying.Get("id"))
}
// ReadyState represents the state of the DataChannel object.
func (d *DataChannel) ReadyState() DataChannelState {
return newDataChannelState(d.underlying.Get("readyState").String())
}
// BufferedAmount represents the number of bytes of application data
// (UTF-8 text and binary data) that have been queued using send(). Even
// though the data transmission can occur in parallel, the returned value
// MUST NOT be decreased before the current task yielded back to the event
// loop to prevent race conditions. The value does not include framing
// overhead incurred by the protocol, or buffering done by the operating
// system or network hardware. The value of BufferedAmount slot will only
// increase with each call to the send() method as long as the ReadyState is
// open; however, BufferedAmount does not reset to zero once the channel
// closes.
func (d *DataChannel) BufferedAmount() uint64 {
return uint64(d.underlying.Get("bufferedAmount").Int())
}
// BufferedAmountLowThreshold represents the threshold at which the
// bufferedAmount is considered to be low. When the bufferedAmount decreases
// from above this threshold to equal or below it, the bufferedamountlow
// event fires. BufferedAmountLowThreshold is initially zero on each new
// DataChannel, but the application may change its value at any time.
func (d *DataChannel) BufferedAmountLowThreshold() uint64 {
return uint64(d.underlying.Get("bufferedAmountLowThreshold").Int())
}
// SetBufferedAmountLowThreshold is used to update the threshold.
// See BufferedAmountLowThreshold().
func (d *DataChannel) SetBufferedAmountLowThreshold(th uint64) {
d.underlying.Set("bufferedAmountLowThreshold", th)
}
// OnBufferedAmountLow sets an event handler which is invoked when
// the number of bytes of outgoing data becomes lower than or equal to the
// BufferedAmountLowThreshold.
func (d *DataChannel) OnBufferedAmountLow(f func()) {
if d.onBufferedAmountLow != nil {
oldHandler := d.onBufferedAmountLow
defer oldHandler.Release()
}
onBufferedAmountLow := js.FuncOf(func(this js.Value, args []js.Value) any {
if d.ReadyState() != DataChannelStateOpen {
return js.Undefined()
}
go f()
return js.Undefined()
})
d.onBufferedAmountLow = &onBufferedAmountLow
d.underlying.Set("onbufferedamountlow", onBufferedAmountLow)
}
// valueToDataChannelMessage converts the given value to a DataChannelMessage.
// val should be obtained from MessageEvent.data where MessageEvent is received
// via the RTCDataChannel.onmessage callback.
func valueToDataChannelMessage(val js.Value) DataChannelMessage {
// If val is of type string, the conversion is straightforward.
if val.Type() == js.TypeString {
return DataChannelMessage{
IsString: true,
Data: []byte(val.String()),
}
}
// For other types, we need to first determine val.constructor.name.
constructorName := val.Get("constructor").Get("name").String()
var data []byte
switch constructorName {
case "Uint8Array":
// We can easily convert Uint8Array to []byte
data = uint8ArrayValueToBytes(val)
case "Blob":
// Convert the Blob to an ArrayBuffer and then convert the ArrayBuffer
// to a Uint8Array.
// See: https://developer.mozilla.org/en-US/docs/Web/API/Blob
// The JavaScript API for reading from the Blob is asynchronous. We use a
// channel to signal when reading is done.
reader := js.Global().Get("FileReader").New()
doneChan := make(chan struct{})
reader.Call("addEventListener", "loadend", js.FuncOf(func(this js.Value, args []js.Value) any {
go func() {
// Signal that the FileReader is done reading/loading by sending through
// the doneChan.
doneChan <- struct{}{}
}()
return js.Undefined()
}))
reader.Call("readAsArrayBuffer", val)
// Wait for the FileReader to finish reading/loading.
<-doneChan
// At this point buffer.result is a typed array, which we know how to
// handle.
buffer := reader.Get("result")
uint8Array := js.Global().Get("Uint8Array").New(buffer)
data = uint8ArrayValueToBytes(uint8Array)
default:
// Assume we have an ArrayBufferView type which we can convert to a
// Uint8Array in JavaScript.
// See: https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView
uint8Array := js.Global().Get("Uint8Array").New(val)
data = uint8ArrayValueToBytes(uint8Array)
}
return DataChannelMessage{
IsString: false,
Data: data,
}
}

75
datachannel_js_detach.go Normal file
View File

@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package webrtc
import (
"errors"
)
type detachedDataChannel struct {
dc *DataChannel
read chan DataChannelMessage
done chan struct{}
}
func newDetachedDataChannel(dc *DataChannel) *detachedDataChannel {
read := make(chan DataChannelMessage)
done := make(chan struct{})
// Wire up callbacks
dc.OnMessage(func(msg DataChannelMessage) {
read <- msg // pion/webrtc/projects/15
})
// pion/webrtc/projects/15
return &detachedDataChannel{
dc: dc,
read: read,
done: done,
}
}
func (c *detachedDataChannel) Read(p []byte) (int, error) {
n, _, err := c.ReadDataChannel(p)
return n, err
}
func (c *detachedDataChannel) ReadDataChannel(p []byte) (int, bool, error) {
select {
case <-c.done:
return 0, false, errors.New("Reader closed")
case msg := <-c.read:
n := copy(p, msg.Data)
if n < len(msg.Data) {
return n, msg.IsString, errors.New("Read buffer to small")
}
return n, msg.IsString, nil
}
}
func (c *detachedDataChannel) Write(p []byte) (n int, err error) {
return c.WriteDataChannel(p, false)
}
func (c *detachedDataChannel) WriteDataChannel(p []byte, isString bool) (n int, err error) {
if isString {
err = c.dc.SendText(string(p))
return len(p), err
}
err = c.dc.Send(p)
return len(p), err
}
func (c *detachedDataChannel) Close() error {
close(c.done)
return c.dc.Close()
}

546
datachannel_test.go Normal file
View File

@@ -0,0 +1,546 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"fmt"
"io"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/pion/transport/v4/test"
"github.com/stretchr/testify/assert"
)
// expectedLabel represents the label of the data channel we are trying to test.
// Some other channels may have been created during initialization (in the Wasm
// bindings this is a requirement).
const expectedLabel = "data"
func closePairNow(tb testing.TB, pc1, pc2 io.Closer) {
tb.Helper()
var fail bool
if err := pc1.Close(); err != nil {
tb.Errorf("Failed to close PeerConnection: %v", err)
fail = true
}
if err := pc2.Close(); err != nil {
tb.Errorf("Failed to close PeerConnection: %v", err)
fail = true
}
if fail {
tb.FailNow()
}
}
func closePair(t *testing.T, pc1, pc2 io.Closer, done <-chan bool) {
t.Helper()
select {
case <-time.After(10 * time.Second):
assert.Fail(t, "closePair timed out waiting for done signal")
case <-done:
closePairNow(t, pc1, pc2)
}
}
func setUpDataChannelParametersTest(
t *testing.T,
options *DataChannelInit,
) (*PeerConnection, *PeerConnection, *DataChannel, chan bool) {
t.Helper()
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
done := make(chan bool)
dc, err := offerPC.CreateDataChannel(expectedLabel, options)
assert.NoError(t, err)
return offerPC, answerPC, dc, done
}
func closeReliabilityParamTest(t *testing.T, pc1, pc2 *PeerConnection, done chan bool) {
t.Helper()
err := signalPair(pc1, pc2)
assert.NoError(t, err)
closePair(t, pc1, pc2, done)
}
func BenchmarkDataChannelSend2(b *testing.B) { benchmarkDataChannelSend(b, 2) }
func BenchmarkDataChannelSend4(b *testing.B) { benchmarkDataChannelSend(b, 4) }
func BenchmarkDataChannelSend8(b *testing.B) { benchmarkDataChannelSend(b, 8) }
func BenchmarkDataChannelSend16(b *testing.B) { benchmarkDataChannelSend(b, 16) }
func BenchmarkDataChannelSend32(b *testing.B) { benchmarkDataChannelSend(b, 32) }
// See https://github.com/pion/webrtc/issues/1516
func benchmarkDataChannelSend(b *testing.B, numChannels int) { //nolint:cyclop
b.Helper()
offerPC, answerPC, err := newPair()
if err != nil {
b.Fatalf("Failed to create a PC pair for testing")
}
defer closePairNow(b, offerPC, answerPC)
type openSignal struct {
local chan struct{}
remote chan struct{}
}
open := make(map[string]openSignal, numChannels)
answerPC.OnDataChannel(func(d *DataChannel) {
signal, ok := open[d.Label()]
if !ok {
// Ignore anything unknown channel label.
return
}
d.OnOpen(func() { close(signal.remote) })
})
dataChannels := make([]*DataChannel, 0, numChannels)
for i := range numChannels {
label := fmt.Sprintf("dc-%d", i)
signal := openSignal{
local: make(chan struct{}),
remote: make(chan struct{}),
}
open[label] = signal
var dc *DataChannel
dc, err = offerPC.CreateDataChannel(label, nil)
if err != nil {
b.Fatalf("Failed to create data channel %q: %v", label, err)
}
dc.OnOpen(func() { close(signal.local) })
dataChannels = append(dataChannels, dc)
}
err = signalPairWithOptions(offerPC, answerPC, withDisableInitialDataChannel(true))
if err != nil {
b.Fatalf("Failed to signal PeerConnection pair: %v", err)
}
for label, signal := range open {
waitForBenchmarkDataChannelOpen(b, label, "local", signal.local)
waitForBenchmarkDataChannelOpen(b, label, "remote", signal.remote)
}
b.ReportAllocs()
b.ResetTimer()
errCh := make(chan error, numChannels)
var wg sync.WaitGroup
wg.Add(len(dataChannels))
for _, dc := range dataChannels {
go func(dc *DataChannel) {
defer wg.Done()
for n := 0; n < b.N/numChannels; n++ {
if dc.ReadyState() != DataChannelStateOpen {
return
}
if err := dc.SendText("Ping"); err != nil {
if dc.ReadyState() != DataChannelStateOpen ||
strings.Contains(err.Error(), "non-established state") {
return
}
errCh <- fmt.Errorf("unexpected error sending data (label=%q): %w", dc.Label(), err)
return
}
}
}(dc)
}
wg.Wait()
b.StopTimer()
close(errCh)
for err := range errCh {
b.Error(err)
}
}
func waitForBenchmarkDataChannelOpen(b *testing.B, label, side string, open <-chan struct{}) {
b.Helper()
select {
case <-open:
case <-time.After(10 * time.Second):
b.Fatalf("Timed out waiting for %s data channel %q to open", side, label)
}
}
func TestDataChannel_Open(t *testing.T) {
const openOnceChannelCapacity = 2
t.Run("handler should be called once", func(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
done := make(chan bool)
openCalls := make(chan bool, openOnceChannelCapacity)
answerPC.OnDataChannel(func(d *DataChannel) {
if d.Label() != expectedLabel {
return
}
d.OnOpen(func() {
openCalls <- true
})
d.OnMessage(func(DataChannelMessage) {
go func() {
// Wait a little bit to ensure all messages are processed.
time.Sleep(100 * time.Millisecond)
done <- true
}()
})
})
dc, err := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, err)
dc.OnOpen(func() {
assert.NoError(t, dc.SendText("Ping"), "Failed to send string on data channel")
})
assert.NoError(t, signalPair(offerPC, answerPC))
closePair(t, offerPC, answerPC, done)
assert.Len(t, openCalls, 1)
})
t.Run("handler should be called once when already negotiated", func(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
done := make(chan bool)
answerOpenCalls := make(chan bool, openOnceChannelCapacity)
offerOpenCalls := make(chan bool, openOnceChannelCapacity)
negotiated := true
ordered := true
dataChannelID := uint16(0)
answerDC, err := answerPC.CreateDataChannel(expectedLabel, &DataChannelInit{
ID: &dataChannelID,
Negotiated: &negotiated,
Ordered: &ordered,
})
assert.NoError(t, err)
offerDC, err := offerPC.CreateDataChannel(expectedLabel, &DataChannelInit{
ID: &dataChannelID,
Negotiated: &negotiated,
Ordered: &ordered,
})
assert.NoError(t, err)
answerDC.OnMessage(func(DataChannelMessage) {
go func() {
// Wait a little bit to ensure all messages are processed.
time.Sleep(100 * time.Millisecond)
done <- true
}()
})
answerDC.OnOpen(func() {
answerOpenCalls <- true
})
offerDC.OnOpen(func() {
offerOpenCalls <- true
assert.NoError(t, offerDC.SendText("Ping"), "Failed to send string on data channel")
})
assert.NoError(t, signalPair(offerPC, answerPC))
closePair(t, offerPC, answerPC, done)
assert.Len(t, answerOpenCalls, 1)
assert.Len(t, offerOpenCalls, 1)
})
}
func TestDataChannel_Send(t *testing.T) { //nolint:cyclop
t.Run("before signaling", func(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
done := make(chan bool)
answerPC.OnDataChannel(func(d *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if d.Label() != expectedLabel {
return
}
d.OnMessage(func(DataChannelMessage) {
assert.NoError(t, d.Send([]byte("Pong")), "Failed to send string on data channel")
})
assert.True(t, d.Ordered(), "Ordered should be set to true")
})
dc, err := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, err)
assert.True(t, dc.Ordered(), "Ordered should be set to true")
dc.OnOpen(func() {
assert.NoError(t, dc.SendText("Ping"), "Failed to send string on data channel")
})
dc.OnMessage(func(DataChannelMessage) {
done <- true
})
err = signalPair(offerPC, answerPC)
assert.NoError(t, err)
closePair(t, offerPC, answerPC, done)
})
t.Run("after connected", func(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
done := make(chan bool)
answerPC.OnDataChannel(func(d *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if d.Label() != expectedLabel {
return
}
d.OnMessage(func(DataChannelMessage) {
assert.NoError(t, d.Send([]byte("Pong")), "Failed to send string on data channel")
})
assert.True(t, d.Ordered(), "Ordered should be set to true")
})
once := &sync.Once{}
offerPC.OnICEConnectionStateChange(func(state ICEConnectionState) {
if state == ICEConnectionStateConnected || state == ICEConnectionStateCompleted {
// wasm fires completed state multiple times
once.Do(func() {
dc, createErr := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, createErr)
assert.True(t, dc.Ordered(), "Ordered should be set to true")
dc.OnMessage(func(DataChannelMessage) {
done <- true
})
if e := dc.SendText("Ping"); e != nil {
// wasm binding doesn't fire OnOpen (we probably already missed it)
dc.OnOpen(func() {
assert.NoError(t, dc.SendText("Ping"), "Failed to send string on data channel")
})
}
})
}
})
err = signalPair(offerPC, answerPC)
assert.NoError(t, err)
closePair(t, offerPC, answerPC, done)
})
}
func TestDataChannel_Close(t *testing.T) {
report := test.CheckRoutines(t)
defer report()
t.Run("Close after PeerConnection Closed", func(t *testing.T) {
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
dc, err := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, err)
closePairNow(t, offerPC, answerPC)
assert.NoError(t, dc.Close())
})
t.Run("Close before connected", func(t *testing.T) {
offerPC, answerPC, err := newPair()
assert.NoError(t, err)
dc, err := offerPC.CreateDataChannel(expectedLabel, nil)
assert.NoError(t, err)
assert.NoError(t, dc.Close())
closePairNow(t, offerPC, answerPC)
})
}
func TestDataChannelParameters(t *testing.T) { //nolint:cyclop
report := test.CheckRoutines(t)
defer report()
t.Run("MaxPacketLifeTime exchange", func(t *testing.T) {
ordered := true
maxPacketLifeTime := uint16(3)
options := &DataChannelInit{
Ordered: &ordered,
MaxPacketLifeTime: &maxPacketLifeTime,
}
offerPC, answerPC, dc, done := setUpDataChannelParametersTest(t, options)
// Check if parameters are correctly set
assert.Equal(t, dc.Ordered(), ordered, "Ordered should be same value as set in DataChannelInit")
if assert.NotNil(t, dc.MaxPacketLifeTime(), "should not be nil") {
assert.Equal(t, maxPacketLifeTime, *dc.MaxPacketLifeTime(), "should match")
}
answerPC.OnDataChannel(func(d *DataChannel) {
if d.Label() != expectedLabel {
return
}
// Check if parameters are correctly set
assert.Equal(t, d.Ordered(), ordered, "Ordered should be same value as set in DataChannelInit")
if assert.NotNil(t, d.MaxPacketLifeTime(), "should not be nil") {
assert.Equal(t, maxPacketLifeTime, *d.MaxPacketLifeTime(), "should match")
}
done <- true
})
closeReliabilityParamTest(t, offerPC, answerPC, done)
})
t.Run("MaxRetransmits exchange", func(t *testing.T) {
ordered := false
maxRetransmits := uint16(3000)
options := &DataChannelInit{
Ordered: &ordered,
MaxRetransmits: &maxRetransmits,
}
offerPC, answerPC, dc, done := setUpDataChannelParametersTest(t, options)
// Check if parameters are correctly set
assert.False(t, dc.Ordered(), "Ordered should be set to false")
if assert.NotNil(t, dc.MaxRetransmits(), "should not be nil") {
assert.Equal(t, maxRetransmits, *dc.MaxRetransmits(), "should match")
}
answerPC.OnDataChannel(func(d *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if d.Label() != expectedLabel {
return
}
// Check if parameters are correctly set
assert.False(t, d.Ordered(), "Ordered should be set to false")
if assert.NotNil(t, d.MaxRetransmits(), "should not be nil") {
assert.Equal(t, maxRetransmits, *d.MaxRetransmits(), "should match")
}
done <- true
})
closeReliabilityParamTest(t, offerPC, answerPC, done)
})
t.Run("Protocol exchange", func(t *testing.T) {
protocol := "json"
options := &DataChannelInit{
Protocol: &protocol,
}
offerPC, answerPC, dc, done := setUpDataChannelParametersTest(t, options)
// Check if parameters are correctly set
assert.Equal(t, protocol, dc.Protocol(), "Protocol should match DataChannelInit")
answerPC.OnDataChannel(func(d *DataChannel) {
// Make sure this is the data channel we were looking for. (Not the one
// created in signalPair).
if d.Label() != expectedLabel {
return
}
// Check if parameters are correctly set
assert.Equal(t, protocol, d.Protocol(), "Protocol should match what channel creator declared")
done <- true
})
closeReliabilityParamTest(t, offerPC, answerPC, done)
})
t.Run("Negotiated exchange", func(t *testing.T) {
const expectedMessage = "Hello World"
negotiated := true
var id uint16 = 500
options := &DataChannelInit{
Negotiated: &negotiated,
ID: &id,
}
offerPC, answerPC, offerDatachannel, done := setUpDataChannelParametersTest(t, options)
answerDatachannel, err := answerPC.CreateDataChannel(expectedLabel, options)
assert.NoError(t, err)
answerPC.OnDataChannel(func(d *DataChannel) {
// Ignore our default channel, exists to force ICE candidates. See signalPair for more info
assert.Equal(t, "initial_data_channel", d.Label(), "OnDataChannel must not be fired when negotiated == true")
})
offerPC.OnDataChannel(func(*DataChannel) {
assert.Fail(t, "OnDataChannel must not be fired when negotiated == true")
})
seenAnswerMessage := &atomic.Bool{}
seenOfferMessage := &atomic.Bool{}
answerDatachannel.OnMessage(func(msg DataChannelMessage) {
if msg.IsString && string(msg.Data) == expectedMessage {
seenAnswerMessage.Store(true)
}
})
offerDatachannel.OnMessage(func(msg DataChannelMessage) {
if msg.IsString && string(msg.Data) == expectedMessage {
seenOfferMessage.Store(true)
}
})
go func() {
for seenAnswerMessage.Load() && seenOfferMessage.Load() {
if offerDatachannel.ReadyState() == DataChannelStateOpen {
assert.NoError(t, offerDatachannel.SendText(expectedMessage))
}
if answerDatachannel.ReadyState() == DataChannelStateOpen {
assert.NoError(t, answerDatachannel.SendText(expectedMessage))
}
time.Sleep(500 * time.Millisecond)
}
done <- true
}()
closeReliabilityParamTest(t, offerPC, answerPC, done)
})
}

36
datachannelinit.go Normal file
View File

@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
// DataChannelInit can be used to configure properties of the underlying
// channel such as data reliability.
type DataChannelInit struct {
// Ordered indicates if data is allowed to be delivered out of order. The
// default value of true, guarantees that data will be delivered in order.
Ordered *bool
// MaxPacketLifeTime limits the time (in milliseconds) during which the
// channel will transmit or retransmit data if not acknowledged. This value
// may be clamped if it exceeds the maximum value supported.
MaxPacketLifeTime *uint16
// MaxRetransmits limits the number of times a channel will retransmit data
// if not successfully delivered. This value may be clamped if it exceeds
// the maximum value supported.
MaxRetransmits *uint16
// Protocol describes the subprotocol name used for this channel.
Protocol *string
// Negotiated describes if the data channel is created by the local peer or
// the remote peer. The default value of false tells the user agent to
// announce the channel in-band and instruct the other peer to dispatch a
// corresponding DataChannel. If set to true, it is up to the application
// to negotiate the channel and create an DataChannel with the same id
// at the other peer.
Negotiated *bool
// ID overrides the default selection of ID for this channel.
ID *uint16
}

13
datachannelmessage.go Normal file
View File

@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
// DataChannelMessage represents a message received from the
// data channel. IsString will be set to true if the incoming
// message is of the string type. Otherwise the message is of
// a binary type.
type DataChannelMessage struct {
IsString bool
Data []byte
}

15
datachannelparameters.go Normal file
View File

@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
// DataChannelParameters describes the configuration of the DataChannel.
type DataChannelParameters struct {
Label string `json:"label"`
Protocol string `json:"protocol"`
ID *uint16 `json:"id"`
Ordered bool `json:"ordered"`
MaxPacketLifeTime *uint16 `json:"maxPacketLifeTime"`
MaxRetransmits *uint16 `json:"maxRetransmits"`
Negotiated bool `json:"negotiated"`
}

79
datachannelstate.go Normal file
View File

@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
// DataChannelState indicates the state of a data channel.
type DataChannelState int
const (
// DataChannelStateUnknown is the enum's zero-value.
DataChannelStateUnknown DataChannelState = iota
// DataChannelStateConnecting indicates that the data channel is being
// established. This is the initial state of DataChannel, whether created
// with CreateDataChannel, or dispatched as a part of an DataChannelEvent.
DataChannelStateConnecting
// DataChannelStateOpen indicates that the underlying data transport is
// established and communication is possible.
DataChannelStateOpen
// DataChannelStateClosing indicates that the procedure to close down the
// underlying data transport has started.
DataChannelStateClosing
// DataChannelStateClosed indicates that the underlying data transport
// has been closed or could not be established.
DataChannelStateClosed
)
// This is done this way because of a linter.
const (
dataChannelStateConnectingStr = "connecting"
dataChannelStateOpenStr = "open"
dataChannelStateClosingStr = "closing"
dataChannelStateClosedStr = "closed"
)
func newDataChannelState(raw string) DataChannelState {
switch raw {
case dataChannelStateConnectingStr:
return DataChannelStateConnecting
case dataChannelStateOpenStr:
return DataChannelStateOpen
case dataChannelStateClosingStr:
return DataChannelStateClosing
case dataChannelStateClosedStr:
return DataChannelStateClosed
default:
return DataChannelStateUnknown
}
}
func (t DataChannelState) String() string {
switch t {
case DataChannelStateConnecting:
return dataChannelStateConnectingStr
case DataChannelStateOpen:
return dataChannelStateOpenStr
case DataChannelStateClosing:
return dataChannelStateClosingStr
case DataChannelStateClosed:
return dataChannelStateClosedStr
default:
return ErrUnknownType.Error()
}
}
// MarshalText implements encoding.TextMarshaler.
func (t DataChannelState) MarshalText() ([]byte, error) {
return []byte(t.String()), nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (t *DataChannelState) UnmarshalText(b []byte) error {
*t = newDataChannelState(string(b))
return nil
}

52
datachannelstate_test.go Normal file
View File

@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewDataChannelState(t *testing.T) {
testCases := []struct {
stateString string
expectedState DataChannelState
}{
{ErrUnknownType.Error(), DataChannelStateUnknown},
{"connecting", DataChannelStateConnecting},
{"open", DataChannelStateOpen},
{"closing", DataChannelStateClosing},
{"closed", DataChannelStateClosed},
}
for i, testCase := range testCases {
assert.Equal(t,
testCase.expectedState,
newDataChannelState(testCase.stateString),
"testCase: %d %v", i, testCase,
)
}
}
func TestDataChannelState_String(t *testing.T) {
testCases := []struct {
state DataChannelState
expectedString string
}{
{DataChannelStateUnknown, ErrUnknownType.Error()},
{DataChannelStateConnecting, "connecting"},
{DataChannelStateOpen, "open"},
{DataChannelStateClosing, "closing"},
{DataChannelStateClosed, "closed"},
}
for i, testCase := range testCases {
assert.Equal(t,
testCase.expectedString,
testCase.state.String(),
"testCase: %d %v", i, testCase,
)
}
}

17
dtlsfingerprint.go Normal file
View File

@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
// DTLSFingerprint specifies the hash function algorithm and certificate
// fingerprint as described in https://tools.ietf.org/html/rfc4572.
type DTLSFingerprint struct {
// Algorithm specifies one of the hash function algorithms defined in
// the 'Hash function Textual Names' registry.
Algorithm string `json:"algorithm"`
// Value specifies the value of the certificate fingerprint in lowercase
// hex string as expressed utilizing the syntax of 'fingerprint' in
// https://tools.ietf.org/html/rfc4572#section-5.
Value string `json:"value"`
}

10
dtlsparameters.go Normal file
View File

@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
// DTLSParameters holds information relating to DTLS configuration.
type DTLSParameters struct {
Role DTLSRole `json:"role"`
Fingerprints []DTLSFingerprint `json:"fingerprints"`
}

97
dtlsrole.go Normal file
View File

@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"github.com/pion/sdp/v3"
)
// DTLSRole indicates the role of the DTLS transport.
type DTLSRole byte
const (
// DTLSRoleUnknown is the enum's zero-value.
DTLSRoleUnknown DTLSRole = iota
// DTLSRoleAuto defines the DTLS role is determined based on
// the resolved ICE role: the ICE controlled role acts as the DTLS
// client and the ICE controlling role acts as the DTLS server.
DTLSRoleAuto
// DTLSRoleClient defines the DTLS client role.
DTLSRoleClient
// DTLSRoleServer defines the DTLS server role.
DTLSRoleServer
)
const (
// https://tools.ietf.org/html/rfc5763
/*
The answerer MUST use either a
setup attribute value of setup:active or setup:passive. Note that
if the answerer uses setup:passive, then the DTLS handshake will
not begin until the answerer is received, which adds additional
latency. setup:active allows the answer and the DTLS handshake to
occur in parallel. Thus, setup:active is RECOMMENDED.
*/
defaultDtlsRoleAnswer = DTLSRoleClient
/*
The endpoint that is the offerer MUST use the setup attribute
value of setup:actpass and be prepared to receive a client_hello
before it receives the answer.
*/
defaultDtlsRoleOffer = DTLSRoleAuto
)
func (r DTLSRole) String() string {
switch r {
case DTLSRoleAuto:
return "auto"
case DTLSRoleClient:
return "client"
case DTLSRoleServer:
return "server"
default:
return ErrUnknownType.Error()
}
}
// Extract the dtls role from a session description. The decision is made from
// the first role we we parse. If no role can be found we return DTLSRoleAuto.
func dtlsRoleFromSDP(sessionDescription *sdp.SessionDescription) DTLSRole {
if sessionDescription == nil {
return DTLSRoleAuto
}
for _, mediaSection := range sessionDescription.MediaDescriptions {
for _, attribute := range mediaSection.Attributes {
if attribute.Key == "setup" {
switch attribute.Value {
case sdp.ConnectionRoleActive.String():
return DTLSRoleClient
case sdp.ConnectionRolePassive.String():
return DTLSRoleServer
default:
return DTLSRoleAuto
}
}
}
}
return DTLSRoleAuto
}
func connectionRoleFromDtlsRole(d DTLSRole) sdp.ConnectionRole {
switch d {
case DTLSRoleClient:
return sdp.ConnectionRoleActive
case DTLSRoleServer:
return sdp.ConnectionRolePassive
case DTLSRoleAuto:
return sdp.ConnectionRoleActpass
default:
return sdp.ConnectionRole(0)
}
}

84
dtlsrole_test.go Normal file
View File

@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"fmt"
"testing"
"github.com/pion/sdp/v3"
"github.com/stretchr/testify/assert"
)
func TestDTLSRole_String(t *testing.T) {
testCases := []struct {
role DTLSRole
expectedString string
}{
{DTLSRoleUnknown, ErrUnknownType.Error()},
{DTLSRoleAuto, "auto"},
{DTLSRoleClient, "client"},
{DTLSRoleServer, "server"},
}
for i, testCase := range testCases {
assert.Equal(t,
testCase.expectedString,
testCase.role.String(),
"testCase: %d %v", i, testCase,
)
}
}
func TestDTLSRoleFromSDP(t *testing.T) {
parseSDP := func(raw string) *sdp.SessionDescription {
parsed := &sdp.SessionDescription{}
assert.NoError(t, parsed.Unmarshal([]byte(raw)))
return parsed
}
const noMedia = `v=0
o=- 4596489990601351948 2 IN IP4 127.0.0.1
s=-
t=0 0
`
const mediaNoSetup = `v=0
o=- 4596489990601351948 2 IN IP4 127.0.0.1
s=-
t=0 0
m=application 47299 DTLS/SCTP 5000
c=IN IP4 192.168.20.129
`
const mediaSetupDeclared = `v=0
o=- 4596489990601351948 2 IN IP4 127.0.0.1
s=-
t=0 0
m=application 47299 DTLS/SCTP 5000
c=IN IP4 192.168.20.129
a=setup:%s
`
testCases := []struct {
test string
sessionDescription *sdp.SessionDescription
expectedRole DTLSRole
}{
{"nil SessionDescription", nil, DTLSRoleAuto},
{"No MediaDescriptions", parseSDP(noMedia), DTLSRoleAuto},
{"MediaDescription, no setup", parseSDP(mediaNoSetup), DTLSRoleAuto},
{"MediaDescription, setup:actpass", parseSDP(fmt.Sprintf(mediaSetupDeclared, "actpass")), DTLSRoleAuto},
{"MediaDescription, setup:passive", parseSDP(fmt.Sprintf(mediaSetupDeclared, "passive")), DTLSRoleServer},
{"MediaDescription, setup:active", parseSDP(fmt.Sprintf(mediaSetupDeclared, "active")), DTLSRoleClient},
}
for _, testCase := range testCases {
assert.Equal(t,
testCase.expectedRole,
dtlsRoleFromSDP(testCase.sessionDescription),
"TestDTLSRoleFromSDP (%s)", testCase.test,
)
}
}

735
dtlstransport.go Normal file
View File

@@ -0,0 +1,735 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package webrtc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/pion/dtls/v3"
"github.com/pion/dtls/v3/pkg/crypto/fingerprint"
"github.com/pion/interceptor"
"github.com/pion/logging"
"github.com/pion/rtcp"
"github.com/pion/srtp/v3"
"github.com/pion/webrtc/v4/internal/mux"
"github.com/pion/webrtc/v4/internal/util"
"github.com/pion/webrtc/v4/pkg/rtcerr"
)
// DTLSTransport allows an application access to information about the DTLS
// transport over which RTP and RTCP packets are sent and received by
// RTPSender and RTPReceiver, as well other data such as SCTP packets sent
// and received by data channels.
type DTLSTransport struct {
lock sync.RWMutex
iceTransport *ICETransport
certificates []Certificate
remoteParameters DTLSParameters
remoteCertificate []byte
state DTLSTransportState
srtpProtectionProfile srtp.ProtectionProfile
onStateChangeHandler func(DTLSTransportState)
internalOnCloseHandler func()
conn *dtls.Conn
srtpSession, srtcpSession atomic.Value
srtpEndpoint, srtcpEndpoint *mux.Endpoint
simulcastStreams []simulcastStreamPair
srtpReady chan struct{}
dtlsMatcher mux.MatchFunc
api *API
log logging.LeveledLogger
}
type simulcastStreamPair struct {
srtp *srtp.ReadStreamSRTP
srtcp *srtp.ReadStreamSRTCP
}
type streamsForSSRCResult struct {
rtpReadStream *srtp.ReadStreamSRTP
rtpInterceptor interceptor.RTPReader
startRTPReaderImmediately bool
rtcpReadStream *srtp.ReadStreamSRTCP
rtcpInterceptor interceptor.RTCPReader
}
type srtpRTPReader struct {
readStream *srtp.ReadStreamSRTP
}
func (r *srtpRTPReader) Read(in []byte, a interceptor.Attributes) (int, interceptor.Attributes, error) {
n, err := r.readStream.Read(in)
return n, a, err
}
// NewDTLSTransport creates a new DTLSTransport.
// This constructor is part of the ORTC API. It is not
// meant to be used together with the basic WebRTC API.
func (api *API) NewDTLSTransport(transport *ICETransport, certificates []Certificate) (*DTLSTransport, error) {
trans := &DTLSTransport{
iceTransport: transport,
api: api,
state: DTLSTransportStateNew,
dtlsMatcher: mux.MatchDTLS,
srtpReady: make(chan struct{}),
log: api.settingEngine.LoggerFactory.NewLogger("DTLSTransport"),
}
if len(certificates) > 0 {
now := time.Now()
for _, x509Cert := range certificates {
if !x509Cert.Expires().IsZero() && now.After(x509Cert.Expires()) {
return nil, &rtcerr.InvalidAccessError{Err: ErrCertificateExpired}
}
trans.certificates = append(trans.certificates, x509Cert)
}
} else {
sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, &rtcerr.UnknownError{Err: err}
}
certificate, err := GenerateCertificate(sk)
if err != nil {
return nil, err
}
trans.certificates = []Certificate{*certificate}
}
return trans, nil
}
// ICETransport returns the currently-configured *ICETransport or nil
// if one has not been configured.
func (t *DTLSTransport) ICETransport() *ICETransport {
t.lock.RLock()
defer t.lock.RUnlock()
return t.iceTransport
}
// onStateChange requires the caller holds the lock.
func (t *DTLSTransport) onStateChange(state DTLSTransportState) {
t.state = state
handler := t.onStateChangeHandler
if handler != nil {
handler(state)
}
}
// OnStateChange sets a handler that is fired when the DTLS
// connection state changes.
func (t *DTLSTransport) OnStateChange(f func(DTLSTransportState)) {
t.lock.Lock()
defer t.lock.Unlock()
t.onStateChangeHandler = f
}
// State returns the current dtls transport state.
func (t *DTLSTransport) State() DTLSTransportState {
t.lock.RLock()
defer t.lock.RUnlock()
return t.state
}
// WriteRTCP sends a user provided RTCP packet to the connected peer. If no peer is connected the
// packet is discarded.
func (t *DTLSTransport) WriteRTCP(pkts []rtcp.Packet) (int, error) {
raw, err := rtcp.Marshal(pkts)
if err != nil {
return 0, err
}
srtcpSession, err := t.getSRTCPSession()
if err != nil {
return 0, err
}
writeStream, err := srtcpSession.OpenWriteStream()
if err != nil {
// nolint
return 0, fmt.Errorf("%w: %v", errPeerConnWriteRTCPOpenWriteStream, err)
}
return writeStream.Write(raw)
}
// GetLocalParameters returns the DTLS parameters of the local DTLSTransport upon construction.
func (t *DTLSTransport) GetLocalParameters() (DTLSParameters, error) {
fingerprints := []DTLSFingerprint{}
for _, c := range t.certificates {
prints, err := c.GetFingerprints()
if err != nil {
return DTLSParameters{}, err
}
fingerprints = append(fingerprints, prints...)
}
return DTLSParameters{
Role: DTLSRoleAuto, // always returns the default role
Fingerprints: fingerprints,
}, nil
}
// GetRemoteCertificate returns the certificate chain in use by the remote side
// returns an empty list prior to selection of the remote certificate.
func (t *DTLSTransport) GetRemoteCertificate() []byte {
t.lock.RLock()
defer t.lock.RUnlock()
return t.remoteCertificate
}
func (t *DTLSTransport) startSRTP() error {
srtpConfig := &srtp.Config{
Profile: t.srtpProtectionProfile,
BufferFactory: t.api.settingEngine.BufferFactory,
LoggerFactory: t.api.settingEngine.LoggerFactory,
}
if t.api.settingEngine.replayProtection.SRTP != nil {
srtpConfig.RemoteOptions = append(
srtpConfig.RemoteOptions,
srtp.SRTPReplayProtection(*t.api.settingEngine.replayProtection.SRTP),
)
}
if t.api.settingEngine.disableSRTPReplayProtection {
srtpConfig.RemoteOptions = append(
srtpConfig.RemoteOptions,
srtp.SRTPNoReplayProtection(),
)
}
if t.api.settingEngine.replayProtection.SRTCP != nil {
srtpConfig.RemoteOptions = append(
srtpConfig.RemoteOptions,
srtp.SRTCPReplayProtection(*t.api.settingEngine.replayProtection.SRTCP),
)
}
if t.api.settingEngine.disableSRTCPReplayProtection {
srtpConfig.RemoteOptions = append(
srtpConfig.RemoteOptions,
srtp.SRTCPNoReplayProtection(),
)
}
connState, ok := t.conn.ConnectionState()
if !ok {
// nolint
return fmt.Errorf("%w: Failed to get DTLS ConnectionState", errDtlsKeyExtractionFailed)
}
err := srtpConfig.ExtractSessionKeysFromDTLS(&connState, t.role() == DTLSRoleClient)
if err != nil {
// nolint
return fmt.Errorf("%w: %v", errDtlsKeyExtractionFailed, err)
}
srtpSession, err := srtp.NewSessionSRTP(t.srtpEndpoint, srtpConfig)
if err != nil {
// nolint
return fmt.Errorf("%w: %v", errFailedToStartSRTP, err)
}
srtcpSession, err := srtp.NewSessionSRTCP(t.srtcpEndpoint, srtpConfig)
if err != nil {
// nolint
return fmt.Errorf("%w: %v", errFailedToStartSRTCP, err)
}
t.srtpSession.Store(srtpSession)
t.srtcpSession.Store(srtcpSession)
close(t.srtpReady)
return nil
}
func (t *DTLSTransport) getSRTPSession() (*srtp.SessionSRTP, error) {
if value, ok := t.srtpSession.Load().(*srtp.SessionSRTP); ok {
return value, nil
}
return nil, errDtlsTransportNotStarted
}
func (t *DTLSTransport) getSRTCPSession() (*srtp.SessionSRTCP, error) {
if value, ok := t.srtcpSession.Load().(*srtp.SessionSRTCP); ok {
return value, nil
}
return nil, errDtlsTransportNotStarted
}
func (t *DTLSTransport) role() DTLSRole {
// If remote has an explicit role use the inverse
switch t.remoteParameters.Role {
case DTLSRoleClient:
return DTLSRoleServer
case DTLSRoleServer:
return DTLSRoleClient
default:
}
// If SettingEngine has an explicit role
switch t.api.settingEngine.answeringDTLSRole {
case DTLSRoleServer:
return DTLSRoleServer
case DTLSRoleClient:
return DTLSRoleClient
default:
}
// Remote was auto and no explicit role was configured via SettingEngine
if t.iceTransport.Role() == ICERoleControlling {
return DTLSRoleServer
}
return defaultDtlsRoleAnswer
}
// Start DTLS transport negotiation with the parameters of the remote DTLS transport.
func (t *DTLSTransport) Start(remoteParameters DTLSParameters) error {
return t.start(remoteParameters, t.handshakeDTLS)
}
// StartContext starts DTLS transport negotiation with the parameters of the remote DTLS
// transport. If the context is canceled before the DTLS handshake is complete, the handshake
// is interrupted and an error is returned.
func (t *DTLSTransport) StartContext(ctx context.Context, remoteParameters DTLSParameters) error {
return t.start(remoteParameters, func(dtlsConn *dtls.Conn) error {
return dtlsConn.HandshakeContext(ctx)
})
}
func (t *DTLSTransport) start(remoteParameters DTLSParameters, handshake func(*dtls.Conn) error) error {
role, certificate, err := t.prepareStart(remoteParameters)
if err != nil {
return err
}
dtlsEndpoint := t.iceTransport.newEndpoint(mux.MatchDTLS)
dtlsEndpoint.SetOnClose(t.internalOnCloseHandler)
sharedOpts := t.dtlsSharedOptions(certificate)
dtlsConn, err := t.connectDTLS(dtlsEndpoint, role, sharedOpts)
if err != nil {
dtlsEndpoint.SetOnClose(nil)
_ = dtlsEndpoint.Close()
return t.failStart(err)
}
if err = handshake(dtlsConn); err != nil {
dtlsEndpoint.SetOnClose(nil)
_ = dtlsConn.Close()
return t.failStart(err)
}
if err = t.completeStart(dtlsConn); err != nil {
dtlsEndpoint.SetOnClose(nil)
_ = dtlsConn.Close()
return err
}
return nil
}
func (t *DTLSTransport) prepareStart(remoteParameters DTLSParameters) (DTLSRole, tls.Certificate, error) {
t.lock.Lock()
defer t.lock.Unlock()
if err := t.ensureICEConn(); err != nil {
return DTLSRole(0), tls.Certificate{}, err
}
if t.state != DTLSTransportStateNew {
return DTLSRole(0), tls.Certificate{}, &rtcerr.InvalidStateError{
Err: fmt.Errorf("%w: %s", errInvalidDTLSStart, t.state),
}
}
t.srtpEndpoint = t.iceTransport.newEndpoint(mux.MatchSRTP)
t.srtcpEndpoint = t.iceTransport.newEndpoint(mux.MatchSRTCP)
t.remoteParameters = remoteParameters
cert := t.certificates[0]
t.onStateChange(DTLSTransportStateConnecting)
return t.role(), tls.Certificate{
Certificate: [][]byte{cert.x509Cert.Raw},
PrivateKey: cert.privateKey,
}, nil
}
func (t *DTLSTransport) dtlsSharedOptions(certificate tls.Certificate) []dtls.Option {
sharedOpts := []dtls.Option{
dtls.WithCertificates(certificate),
dtls.WithSRTPProtectionProfiles(t.srtpProtectionProfiles()...),
dtls.WithExtendedMasterSecret(t.api.settingEngine.dtls.extendedMasterSecret),
dtls.WithInsecureSkipVerify(!t.api.settingEngine.dtls.disableInsecureSkipVerify),
dtls.WithLoggerFactory(t.api.settingEngine.LoggerFactory),
dtls.WithVerifyPeerCertificate(t.verifyPeerCertificateFunc()),
}
if t.api.settingEngine.dtls.customCipherSuites != nil {
sharedOpts = append(
sharedOpts,
dtls.WithCustomCipherSuites(t.api.settingEngine.dtls.customCipherSuites),
)
}
if t.api.settingEngine.dtls.retransmissionInterval > 0 {
sharedOpts = append(
sharedOpts,
dtls.WithFlightInterval(t.api.settingEngine.dtls.retransmissionInterval),
)
}
if t.api.settingEngine.replayProtection.DTLS != nil {
sharedOpts = append(
sharedOpts,
dtls.WithReplayProtectionWindow(int(*t.api.settingEngine.replayProtection.DTLS)), //nolint:gosec // G115
)
}
if t.api.settingEngine.dtls.cipherSuites != nil {
sharedOpts = append(
sharedOpts,
dtls.WithCipherSuites(t.api.settingEngine.dtls.cipherSuites...),
)
}
if len(t.api.settingEngine.dtls.ellipticCurves) > 0 {
sharedOpts = append(
sharedOpts,
dtls.WithEllipticCurves(t.api.settingEngine.dtls.ellipticCurves...),
)
}
if t.api.settingEngine.dtls.rootCAs != nil {
sharedOpts = append(sharedOpts, dtls.WithRootCAs(t.api.settingEngine.dtls.rootCAs))
}
if t.api.settingEngine.dtls.keyLogWriter != nil {
sharedOpts = append(sharedOpts, dtls.WithKeyLogWriter(t.api.settingEngine.dtls.keyLogWriter))
}
if len(t.api.settingEngine.dtls.supportedProtocols) > 0 {
sharedOpts = append(
sharedOpts,
dtls.WithSupportedProtocols(t.api.settingEngine.dtls.supportedProtocols...),
)
}
return sharedOpts
}
func (t *DTLSTransport) srtpProtectionProfiles() []dtls.SRTPProtectionProfile {
if len(t.api.settingEngine.srtpProtectionProfiles) > 0 {
return t.api.settingEngine.srtpProtectionProfiles
}
return defaultSrtpProtectionProfiles()
}
func (t *DTLSTransport) verifyPeerCertificateFunc() func([][]byte, [][]*x509.Certificate) error {
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errNoRemoteCertificate
}
t.lock.Lock()
defer t.lock.Unlock()
t.remoteCertificate = rawCerts[0]
if t.api.settingEngine.disableCertificateFingerprintVerification {
return nil
}
parsedRemoteCert, err := x509.ParseCertificate(t.remoteCertificate)
if err != nil {
return err
}
return t.validateFingerPrint(parsedRemoteCert)
}
}
func (t *DTLSTransport) connectDTLS(
dtlsEndpoint *mux.Endpoint,
role DTLSRole,
sharedOpts []dtls.Option,
) (*dtls.Conn, error) {
if role == DTLSRoleClient {
clientOpts := t.toDTLSClientOptions(sharedOpts)
return dtls.ClientWithOptions(
dtlsEndpoint,
dtlsEndpoint.RemoteAddr(),
clientOpts...,
)
}
serverOpts := t.toDTLSServerOptions(sharedOpts)
return dtls.ServerWithOptions(
dtlsEndpoint,
dtlsEndpoint.RemoteAddr(),
serverOpts...,
)
}
func (t *DTLSTransport) toDTLSServerOptions(sharedOpts []dtls.Option) []dtls.ServerOption {
serverOpts := make([]dtls.ServerOption, 0, len(sharedOpts)+5)
for _, opt := range sharedOpts {
serverOpts = append(serverOpts, opt)
}
clientAuth := dtls.RequireAnyClientCert
if t.api.settingEngine.dtls.clientAuth != nil {
clientAuth = *t.api.settingEngine.dtls.clientAuth
}
serverOpts = append(serverOpts,
dtls.WithClientAuth(clientAuth),
dtls.WithClientCAs(t.api.settingEngine.dtls.clientCAs),
dtls.WithInsecureSkipVerifyHello(t.api.settingEngine.dtls.insecureSkipHelloVerify),
)
if t.api.settingEngine.dtls.serverHelloMessageHook != nil {
serverOpts = append(
serverOpts,
dtls.WithServerHelloMessageHook(t.api.settingEngine.dtls.serverHelloMessageHook),
)
}
if t.api.settingEngine.dtls.certificateRequestMessageHook != nil {
serverOpts = append(
serverOpts,
dtls.WithCertificateRequestMessageHook(t.api.settingEngine.dtls.certificateRequestMessageHook),
)
}
return serverOpts
}
func (t *DTLSTransport) toDTLSClientOptions(sharedOpts []dtls.Option) []dtls.ClientOption {
clientOpts := make([]dtls.ClientOption, 0, len(sharedOpts)+1)
for _, opt := range sharedOpts {
clientOpts = append(clientOpts, opt)
}
if t.api.settingEngine.dtls.clientHelloMessageHook != nil {
clientOpts = append(
clientOpts,
dtls.WithClientHelloMessageHook(t.api.settingEngine.dtls.clientHelloMessageHook),
)
}
return clientOpts
}
func (t *DTLSTransport) handshakeDTLS(dtlsConn *dtls.Conn) error {
if t.api.settingEngine.dtls.connectContextMaker == nil {
return dtlsConn.Handshake()
}
handshakeCtx, cancel := t.api.settingEngine.dtls.connectContextMaker()
if cancel != nil {
defer cancel()
}
return dtlsConn.HandshakeContext(handshakeCtx)
}
func (t *DTLSTransport) completeStart(dtlsConn *dtls.Conn) error {
srtpProtectionProfile, err := srtpProtectionProfileFromDTLSConn(dtlsConn)
t.lock.Lock()
defer t.lock.Unlock()
if err != nil {
t.onStateChange(DTLSTransportStateFailed)
return err
}
t.srtpProtectionProfile = srtpProtectionProfile
t.conn = dtlsConn
t.onStateChange(DTLSTransportStateConnected)
return t.startSRTP()
}
func (t *DTLSTransport) failStart(err error) error {
t.lock.Lock()
defer t.lock.Unlock()
t.onStateChange(DTLSTransportStateFailed)
return err
}
func srtpProtectionProfileFromDTLSConn(dtlsConn *dtls.Conn) (srtp.ProtectionProfile, error) {
srtpProfile, ok := dtlsConn.SelectedSRTPProtectionProfile()
if !ok {
return 0, ErrNoSRTPProtectionProfile
}
return srtpProtectionProfileFromDTLS(srtpProfile)
}
func srtpProtectionProfileFromDTLS(srtpProfile dtls.SRTPProtectionProfile) (srtp.ProtectionProfile, error) {
switch srtpProfile {
case dtls.SRTP_AEAD_AES_128_GCM:
return srtp.ProtectionProfileAeadAes128Gcm, nil
case dtls.SRTP_AEAD_AES_256_GCM:
return srtp.ProtectionProfileAeadAes256Gcm, nil
case dtls.SRTP_AES128_CM_HMAC_SHA1_80:
return srtp.ProtectionProfileAes128CmHmacSha1_80, nil
case dtls.SRTP_NULL_HMAC_SHA1_80:
return srtp.ProtectionProfileNullHmacSha1_80, nil
default:
return 0, ErrNoSRTPProtectionProfile
}
}
// Stop stops and closes the DTLSTransport object.
func (t *DTLSTransport) Stop() error {
t.lock.Lock()
defer t.lock.Unlock()
// Try closing everything and collect the errors
var closeErrs []error
if srtpSession, err := t.getSRTPSession(); err == nil && srtpSession != nil {
closeErrs = append(closeErrs, srtpSession.Close())
}
if srtcpSession, err := t.getSRTCPSession(); err == nil && srtcpSession != nil {
closeErrs = append(closeErrs, srtcpSession.Close())
}
for i := range t.simulcastStreams {
closeErrs = append(closeErrs, t.simulcastStreams[i].srtp.Close())
closeErrs = append(closeErrs, t.simulcastStreams[i].srtcp.Close())
}
if t.conn != nil {
// dtls connection may be closed on sctp close.
if err := t.conn.Close(); err != nil && !errors.Is(err, dtls.ErrConnClosed) {
closeErrs = append(closeErrs, err)
}
}
t.onStateChange(DTLSTransportStateClosed)
return util.FlattenErrs(closeErrs)
}
func (t *DTLSTransport) validateFingerPrint(remoteCert *x509.Certificate) error {
for _, fp := range t.remoteParameters.Fingerprints {
hashAlgo, err := fingerprint.HashFromString(fp.Algorithm)
if err != nil {
return err
}
remoteValue, err := fingerprint.Fingerprint(remoteCert, hashAlgo)
if err != nil {
return err
}
if strings.EqualFold(remoteValue, fp.Value) {
return nil
}
}
return errNoMatchingCertificateFingerprint
}
func (t *DTLSTransport) ensureICEConn() error {
if t.iceTransport == nil {
return errICEConnectionNotStarted
}
return nil
}
func (t *DTLSTransport) storeSimulcastStream(
srtpReadStream *srtp.ReadStreamSRTP,
srtcpReadStream *srtp.ReadStreamSRTCP,
) {
t.lock.Lock()
defer t.lock.Unlock()
t.simulcastStreams = append(t.simulcastStreams, simulcastStreamPair{srtpReadStream, srtcpReadStream})
}
func (t *DTLSTransport) streamsForSSRC(
ssrc SSRC,
streamInfo interceptor.StreamInfo,
) (*streamsForSSRCResult, error) {
srtpSession, err := t.getSRTPSession()
if err != nil {
return nil, err
}
rtpReadStream, err := srtpSession.OpenReadStream(uint32(ssrc))
if err != nil {
return nil, err
}
rtpReader := &srtpRTPReader{readStream: rtpReadStream}
rtpInterceptor := t.api.interceptor.BindRemoteStream(&streamInfo, rtpReader)
srtcpSession, err := t.getSRTCPSession()
if err != nil {
return nil, err
}
rtcpReadStream, err := srtcpSession.OpenReadStream(uint32(ssrc))
if err != nil {
return nil, err
}
rtcpInterceptor := t.api.interceptor.BindRTCPReader(interceptor.RTCPReaderFunc(
func(in []byte, a interceptor.Attributes) (n int, attributes interceptor.Attributes, err error) {
n, err = rtcpReadStream.Read(in)
return n, a, err
}),
)
return &streamsForSSRCResult{
rtpReadStream: rtpReadStream,
rtpInterceptor: rtpInterceptor,
startRTPReaderImmediately: rtpInterceptor != rtpReader && t.api.settingEngine.BufferFactory == nil,
rtcpReadStream: rtcpReadStream,
rtcpInterceptor: rtcpInterceptor,
}, nil
}

65
dtlstransport_js.go Normal file
View File

@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package webrtc
import "syscall/js"
// DTLSTransport allows an application access to information about the DTLS
// transport over which RTP and RTCP packets are sent and received by
// RTPSender and RTPReceiver, as well other data such as SCTP packets sent
// and received by data channels.
type DTLSTransport struct {
// Pointer to the underlying JavaScript DTLSTransport object.
underlying js.Value
}
// JSValue returns the underlying RTCDtlsTransport
func (r *DTLSTransport) JSValue() js.Value {
return r.underlying
}
// ICETransport returns the currently-configured *ICETransport or nil
// if one has not been configured
func (r *DTLSTransport) ICETransport() *ICETransport {
underlying := r.underlying.Get("iceTransport")
if underlying.IsNull() || underlying.IsUndefined() {
return nil
}
return &ICETransport{
underlying: underlying,
}
}
func (t *DTLSTransport) GetRemoteCertificate() []byte {
if t.underlying.IsNull() || t.underlying.IsUndefined() {
return nil
}
// Firefox does not support getRemoteCertificates: https://bugzilla.mozilla.org/show_bug.cgi?id=1805446
jsGet := t.underlying.Get("getRemoteCertificates")
if jsGet.IsUndefined() || jsGet.IsNull() {
return nil
}
jsCerts := t.underlying.Call("getRemoteCertificates")
if jsCerts.Length() == 0 {
return nil
}
buf := jsCerts.Index(0)
u8 := js.Global().Get("Uint8Array").New(buf)
if u8.Length() == 0 {
return nil
}
cert := make([]byte, u8.Length())
js.CopyBytesToGo(cert, u8)
return cert
}

638
dtlstransport_test.go Normal file
View File

@@ -0,0 +1,638 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package webrtc
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"net"
"reflect"
"regexp"
"testing"
"time"
"github.com/pion/dtls/v3"
dtlsElliptic "github.com/pion/dtls/v3/pkg/crypto/elliptic"
"github.com/pion/dtls/v3/pkg/protocol/handshake"
"github.com/pion/srtp/v3"
"github.com/pion/transport/v4/test"
"github.com/pion/webrtc/v4/internal/mux"
"github.com/stretchr/testify/assert"
)
// An invalid fingerprint MUST cause DTLSTransport to go to failed state.
func TestInvalidFingerprintCausesFailed(t *testing.T) { //nolint:cyclop
lim := test.TimeOut(time.Second * 10)
defer lim.Stop()
report := test.CheckRoutines(t)
defer report()
pcOffer, err := NewPeerConnection(Configuration{})
assert.NoError(t, err)
pcAnswer, err := NewPeerConnection(Configuration{})
assert.NoError(t, err)
pcAnswer.OnDataChannel(func(_ *DataChannel) {
assert.Fail(t, "A DataChannel must not be created when Fingerprint verification fails")
})
defer closePairNow(t, pcOffer, pcAnswer)
// Set up DTLS state tracking BEFORE starting the connection process
// to avoid missing the state transition
offerDTLSFailed := make(chan struct{})
answerDTLSFailed := make(chan struct{})
pcOffer.SCTP().Transport().OnStateChange(func(state DTLSTransportState) {
if state == DTLSTransportStateFailed {
select {
case <-offerDTLSFailed:
// Already closed
default:
close(offerDTLSFailed)
}
}
})
pcAnswer.SCTP().Transport().OnStateChange(func(state DTLSTransportState) {
if state == DTLSTransportStateFailed {
select {
case <-answerDTLSFailed:
// Already closed
default:
close(answerDTLSFailed)
}
}
})
offerChan := make(chan SessionDescription)
pcOffer.OnICECandidate(func(candidate *ICECandidate) {
if candidate == nil {
offerChan <- *pcOffer.PendingLocalDescription()
}
})
// Also wait for PeerConnection to close (may take longer due to cleanup)
offerConnectionHasClosed := untilConnectionState(PeerConnectionStateClosed, pcOffer)
answerConnectionHasClosed := untilConnectionState(PeerConnectionStateClosed, pcAnswer)
_, err = pcOffer.CreateDataChannel("unusedDataChannel", nil)
assert.NoError(t, err)
offer, err := pcOffer.CreateOffer(nil)
assert.NoError(t, err)
assert.NoError(t, pcOffer.SetLocalDescription(offer))
select {
case offer := <-offerChan:
// Replace with invalid fingerprint
re := regexp.MustCompile(`sha-256 (.*?)\r`)
offer.SDP = re.ReplaceAllString(
offer.SDP,
"sha-256 AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA\r",
)
assert.NoError(t, pcAnswer.SetRemoteDescription(offer))
answer, err := pcAnswer.CreateAnswer(nil)
assert.NoError(t, err)
assert.NoError(t, pcAnswer.SetLocalDescription(answer))
answer.SDP = re.ReplaceAllString(
answer.SDP,
"sha-256 AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA\r",
)
assert.NoError(t, pcOffer.SetRemoteDescription(answer))
case <-time.After(5 * time.Second):
assert.Fail(t, "timed out waiting to receive offer")
}
// Wait for DTLS to fail (should happen quickly after ICE connects, ~1-2 seconds normally,
// but may take longer with race detector due to ICE connectivity checks)
select {
case <-offerDTLSFailed:
// Expected - offer DTLS failed due to invalid fingerprint
case <-time.After(7 * time.Second):
assert.Fail(t, "timed out waiting for offer DTLS to fail")
}
select {
case <-answerDTLSFailed:
// Expected - answer DTLS failed due to invalid fingerprint
case <-time.After(7 * time.Second):
assert.Fail(t, "timed out waiting for answer DTLS to fail")
}
// Wait for PeerConnection to close (may take longer due to cleanup)
offerConnectionHasClosed.Wait()
answerConnectionHasClosed.Wait()
assert.Contains(
t, []DTLSTransportState{DTLSTransportStateClosed, DTLSTransportStateFailed}, pcOffer.SCTP().Transport().State(),
"DTLS Transport should be closed or failed",
)
assert.Nil(t, pcOffer.SCTP().Transport().conn)
assert.Contains(
t, []DTLSTransportState{DTLSTransportStateClosed, DTLSTransportStateFailed}, pcAnswer.SCTP().Transport().State(),
"DTLS Transport should be closed or failed",
)
assert.Nil(t, pcAnswer.SCTP().Transport().conn)
}
func TestPeerConnection_DTLSRoleSettingEngine(t *testing.T) {
runTest := func(r DTLSRole) {
s := SettingEngine{}
assert.NoError(t, s.SetAnsweringDTLSRole(r))
offerPC, err := NewAPI(WithSettingEngine(s)).NewPeerConnection(Configuration{})
assert.NoError(t, err)
answerPC, err := NewAPI(WithSettingEngine(s)).NewPeerConnection(Configuration{})
assert.NoError(t, err)
assert.NoError(t, signalPair(offerPC, answerPC))
connectionComplete := untilConnectionState(PeerConnectionStateConnected, answerPC)
connectionComplete.Wait()
closePairNow(t, offerPC, answerPC)
}
report := test.CheckRoutines(t)
defer report()
t.Run("Server", func(*testing.T) {
runTest(DTLSRoleServer)
})
t.Run("Client", func(*testing.T) {
runTest(DTLSRoleClient)
})
}
type errConn struct {
localAddr net.Addr
remoteAddr net.Addr
readErr error
writeErr error
}
func (c *errConn) Read([]byte) (int, error) { return 0, c.readErr }
func (c *errConn) Write([]byte) (int, error) { return 0, c.writeErr }
func (c *errConn) Close() error { return nil }
func (c *errConn) LocalAddr() net.Addr { return c.localAddr }
func (c *errConn) RemoteAddr() net.Addr { return c.remoteAddr }
func (c *errConn) SetDeadline(time.Time) error { return nil }
func (c *errConn) SetReadDeadline(time.Time) error { return nil }
func (c *errConn) SetWriteDeadline(time.Time) error { return nil }
type failingPacketConn struct {
localAddr net.Addr
readErr error
writeErr error
}
var errTestWriteFailed = errors.New("write failed")
func (c *failingPacketConn) ReadFrom([]byte) (int, net.Addr, error) {
return 0, c.localAddr, c.readErr
}
func (c *failingPacketConn) WriteTo([]byte, net.Addr) (int, error) {
return 0, c.writeErr
}
func (c *failingPacketConn) Close() error { return nil }
func (c *failingPacketConn) LocalAddr() net.Addr { return c.localAddr }
func (c *failingPacketConn) SetDeadline(time.Time) error { return nil }
func (c *failingPacketConn) SetReadDeadline(time.Time) error { return nil }
func (c *failingPacketConn) SetWriteDeadline(time.Time) error { return nil }
func TestDTLSTransport_Start_ErrICEConnectionNotStarted(t *testing.T) {
api := NewAPI()
connectContextMakerCalled := false
api.settingEngine.dtls.connectContextMaker = func() (context.Context, func()) {
connectContextMakerCalled = true
return context.Background(), nil
}
transport := &DTLSTransport{api: api, state: DTLSTransportStateNew}
err := transport.Start(DTLSParameters{Role: DTLSRoleServer})
assert.ErrorIs(t, err, errICEConnectionNotStarted)
assert.Equal(t, DTLSTransportStateNew, transport.State())
assert.False(t, connectContextMakerCalled)
}
func TestDTLSTransport_Start_UsesConnectContextMaker(t *testing.T) {
lim := test.TimeOut(time.Second)
defer lim.Stop()
report := test.CheckRoutines(t)
defer report()
api := NewAPI()
loggerFactory := api.settingEngine.LoggerFactory
connectContextMakerCalled := false
cancelCalled := false
api.settingEngine.dtls.connectContextMaker = func() (context.Context, func()) {
connectContextMakerCalled = true
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx, func() {
cancelCalled = true
}
}
conn := &errConn{
localAddr: &net.UDPAddr{IP: net.IPv4zero, Port: 1},
remoteAddr: &net.UDPAddr{IP: net.IPv4zero, Port: 2},
readErr: io.EOF,
writeErr: errTestWriteFailed,
}
iceTransport := NewICETransport(nil, loggerFactory)
iceTransport.mux = mux.NewMux(mux.Config{
Conn: conn,
BufferSize: 1500,
LoggerFactory: loggerFactory,
})
defer func() { _ = iceTransport.mux.Close() }()
transport, err := api.NewDTLSTransport(iceTransport, nil)
assert.NoError(t, err)
transport.OnStateChange(func(state DTLSTransportState) {
if state == DTLSTransportStateConnecting {
assert.False(t, connectContextMakerCalled)
}
})
err = transport.Start(DTLSParameters{Role: DTLSRoleServer})
assert.ErrorIs(t, err, context.Canceled)
assert.Equal(t, DTLSTransportStateFailed, transport.State())
assert.True(t, connectContextMakerCalled)
assert.True(t, cancelCalled)
}
func TestDTLSTransport_Start_ConnectErrorFailsTransport(t *testing.T) {
lim := test.TimeOut(time.Second)
defer lim.Stop()
report := test.CheckRoutines(t)
defer report()
api := NewAPI()
loggerFactory := api.settingEngine.LoggerFactory
localConn, remoteConn := net.Pipe()
defer func() { _ = remoteConn.Close() }()
iceTransport := NewICETransport(nil, loggerFactory)
iceTransport.mux = mux.NewMux(mux.Config{
Conn: localConn,
BufferSize: 1500,
LoggerFactory: loggerFactory,
})
defer func() { _ = iceTransport.mux.Close() }()
transport, err := api.NewDTLSTransport(iceTransport, nil)
assert.NoError(t, err)
assert.Equal(t, DTLSTransportStateNew, transport.State())
transport.api.settingEngine.dtls.cipherSuites = []dtls.CipherSuiteID{}
err = transport.Start(DTLSParameters{Role: DTLSRoleServer})
assert.Error(t, err)
assert.Equal(t, DTLSTransportStateFailed, transport.State())
assert.Nil(t, transport.conn)
assert.Equal(t, 2, reflect.ValueOf(iceTransport.mux).Elem().FieldByName("endpoints").Len())
}
func TestDTLSTransport_Start_HandshakeErrorFailsTransport(t *testing.T) {
lim := test.TimeOut(time.Second)
defer lim.Stop()
report := test.CheckRoutines(t)
defer report()
api := NewAPI()
loggerFactory := api.settingEngine.LoggerFactory
conn := &errConn{
localAddr: &net.UDPAddr{IP: net.IPv4zero, Port: 1},
remoteAddr: &net.UDPAddr{IP: net.IPv4zero, Port: 2},
readErr: io.EOF,
writeErr: errTestWriteFailed,
}
iceTransport := NewICETransport(nil, loggerFactory)
iceTransport.mux = mux.NewMux(mux.Config{
Conn: conn,
BufferSize: 1500,
LoggerFactory: loggerFactory,
})
defer func() { _ = iceTransport.mux.Close() }()
transport, err := api.NewDTLSTransport(iceTransport, nil)
assert.NoError(t, err)
assert.Equal(t, DTLSTransportStateNew, transport.State())
err = transport.Start(DTLSParameters{Role: DTLSRoleServer})
assert.Error(t, err)
assert.Equal(t, DTLSTransportStateFailed, transport.State())
assert.Nil(t, transport.conn)
assert.Equal(t, 2, reflect.ValueOf(iceTransport.mux).Elem().FieldByName("endpoints").Len())
}
func TestDTLSTransport_dtlsSharedOptions_IncludesOptionalOptions(t *testing.T) {
baseAPI := NewAPI()
baseTransport := &DTLSTransport{api: baseAPI}
baseCount := len(baseTransport.dtlsSharedOptions(tls.Certificate{}))
tests := []struct {
name string
configure func(*SettingEngine)
wantExtra int
}{
{
name: "CustomCipherSuites",
configure: func(se *SettingEngine) {
se.dtls.customCipherSuites = func() []dtls.CipherSuite {
return nil
}
},
wantExtra: 1,
},
{
name: "FlightInterval",
configure: func(se *SettingEngine) {
se.dtls.retransmissionInterval = time.Second
},
wantExtra: 1,
},
{
name: "ReplayProtectionWindow",
configure: func(se *SettingEngine) {
window := uint(1)
se.replayProtection.DTLS = &window
},
wantExtra: 1,
},
{
name: "CipherSuites",
configure: func(se *SettingEngine) {
se.dtls.cipherSuites = []dtls.CipherSuiteID{
dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
}
},
wantExtra: 1,
},
{
name: "EllipticCurves",
configure: func(se *SettingEngine) {
se.dtls.ellipticCurves = []dtlsElliptic.Curve{dtlsElliptic.P256}
},
wantExtra: 1,
},
{
name: "RootCAs",
configure: func(se *SettingEngine) {
se.dtls.rootCAs = x509.NewCertPool()
},
wantExtra: 1,
},
{
name: "KeyLogWriter",
configure: func(se *SettingEngine) {
se.dtls.keyLogWriter = &bytes.Buffer{}
},
wantExtra: 1,
},
{
name: "AllOptional",
configure: func(se *SettingEngine) {
se.dtls.customCipherSuites = func() []dtls.CipherSuite {
return nil
}
se.dtls.retransmissionInterval = time.Second
window := uint(1)
se.replayProtection.DTLS = &window
se.dtls.cipherSuites = []dtls.CipherSuiteID{
dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
}
se.dtls.ellipticCurves = []dtlsElliptic.Curve{dtlsElliptic.P256}
se.dtls.rootCAs = x509.NewCertPool()
se.dtls.keyLogWriter = &bytes.Buffer{}
},
wantExtra: 7,
},
{
name: "SupportedProtocols",
configure: func(se *SettingEngine) {
se.dtls.supportedProtocols = []string{"webrtc", "c-webrtc"}
},
wantExtra: 1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
api := NewAPI()
tc.configure(api.settingEngine)
transport := &DTLSTransport{api: api}
opts := transport.dtlsSharedOptions(tls.Certificate{})
assert.Len(t, opts, baseCount+tc.wantExtra)
})
}
}
func TestDTLSTransport_toDTLSClientOptions_IncludesOptionalOptions(t *testing.T) {
baseAPI := NewAPI()
baseTransport := &DTLSTransport{api: baseAPI}
baseSharedOpts := baseTransport.dtlsSharedOptions(tls.Certificate{})
baseCount := len(baseTransport.toDTLSClientOptions(baseSharedOpts))
api := NewAPI()
api.settingEngine.dtls.clientHelloMessageHook = func(m handshake.MessageClientHello) handshake.Message {
return &m
}
transport := &DTLSTransport{api: api}
sharedOpts := transport.dtlsSharedOptions(tls.Certificate{})
opts := transport.toDTLSClientOptions(sharedOpts)
assert.Len(t, opts, baseCount+1)
}
func TestDTLSTransport_verifyPeerCertificateFunc_NoRemoteCertificate(t *testing.T) {
api := NewAPI()
transport := &DTLSTransport{api: api}
err := transport.verifyPeerCertificateFunc()(nil, nil)
assert.ErrorIs(t, err, errNoRemoteCertificate)
assert.Nil(t, transport.GetRemoteCertificate())
}
func TestDTLSTransport_verifyPeerCertificateFunc_ParseError(t *testing.T) {
api := NewAPI()
transport := &DTLSTransport{api: api}
rawCert := []byte("not a certificate")
err := transport.verifyPeerCertificateFunc()([][]byte{rawCert}, nil)
assert.Error(t, err)
assert.Equal(t, rawCert, transport.GetRemoteCertificate())
}
func TestDTLSTransport_toDTLSServerOptions_IncludesOptionalOptions(t *testing.T) {
baseAPI := NewAPI()
baseTransport := &DTLSTransport{api: baseAPI}
baseCount := len(baseTransport.toDTLSServerOptions(nil))
tests := []struct {
name string
configure func(*SettingEngine)
wantExtra int
}{
{
name: "ServerHelloMessageHook",
configure: func(se *SettingEngine) {
se.dtls.serverHelloMessageHook = func(m handshake.MessageServerHello) handshake.Message {
return &m
}
},
wantExtra: 1,
},
{
name: "CertificateRequestMessageHook",
configure: func(se *SettingEngine) {
se.dtls.certificateRequestMessageHook = func(m handshake.MessageCertificateRequest) handshake.Message {
return &m
}
},
wantExtra: 1,
},
{
name: "AllOptional",
configure: func(se *SettingEngine) {
se.dtls.serverHelloMessageHook = func(m handshake.MessageServerHello) handshake.Message {
return &m
}
se.dtls.certificateRequestMessageHook = func(m handshake.MessageCertificateRequest) handshake.Message {
return &m
}
},
wantExtra: 2,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
api := NewAPI()
tc.configure(api.settingEngine)
transport := &DTLSTransport{api: api}
opts := transport.toDTLSServerOptions(nil)
assert.Len(t, opts, baseCount+tc.wantExtra)
})
}
}
func TestDTLSTransport_handshakeDTLS_DeferredCancel(t *testing.T) {
lim := test.TimeOut(time.Second)
defer lim.Stop()
api := NewAPI()
transport := &DTLSTransport{api: api}
connectContextMakerCalled := false
cancelCalled := false
api.settingEngine.dtls.connectContextMaker = func() (context.Context, func()) {
connectContextMakerCalled = true
ctx, cancel := context.WithCancel(context.Background())
return ctx, func() {
cancelCalled = true
cancel()
}
}
packetConn := &failingPacketConn{
localAddr: &net.UDPAddr{IP: net.IPv4zero, Port: 1},
readErr: io.EOF,
writeErr: errTestWriteFailed,
}
dtlsConn, err := dtls.ClientWithOptions(packetConn, &net.UDPAddr{IP: net.IPv4zero, Port: 2})
assert.NoError(t, err)
defer func() { _ = dtlsConn.Close() }()
err = transport.handshakeDTLS(dtlsConn)
assert.Error(t, err)
assert.True(t, connectContextMakerCalled)
assert.True(t, cancelCalled)
}
func TestSRTPProtectionProfileFromDTLS(t *testing.T) {
tests := []struct {
name string
profile dtls.SRTPProtectionProfile
want srtp.ProtectionProfile
wantErr error
}{
{
name: "SRTP_AEAD_AES_128_GCM",
profile: dtls.SRTP_AEAD_AES_128_GCM,
want: srtp.ProtectionProfileAeadAes128Gcm,
},
{
name: "SRTP_AEAD_AES_256_GCM",
profile: dtls.SRTP_AEAD_AES_256_GCM,
want: srtp.ProtectionProfileAeadAes256Gcm,
},
{
name: "SRTP_AES128_CM_HMAC_SHA1_80",
profile: dtls.SRTP_AES128_CM_HMAC_SHA1_80,
want: srtp.ProtectionProfileAes128CmHmacSha1_80,
},
{
name: "SRTP_NULL_HMAC_SHA1_80",
profile: dtls.SRTP_NULL_HMAC_SHA1_80,
want: srtp.ProtectionProfileNullHmacSha1_80,
},
{
name: "Unknown",
profile: dtls.SRTPProtectionProfile(255),
wantErr: ErrNoSRTPProtectionProfile,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := srtpProtectionProfileFromDTLS(tc.profile)
if tc.wantErr != nil {
assert.ErrorIs(t, err, tc.wantErr)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}

89
dtlstransportstate.go Normal file
View File

@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
// DTLSTransportState indicates the DTLS transport establishment state.
type DTLSTransportState int
const (
// DTLSTransportStateUnknown is the enum's zero-value.
DTLSTransportStateUnknown DTLSTransportState = iota
// DTLSTransportStateNew indicates that DTLS has not started negotiating
// yet.
DTLSTransportStateNew
// DTLSTransportStateConnecting indicates that DTLS is in the process of
// negotiating a secure connection and verifying the remote fingerprint.
DTLSTransportStateConnecting
// DTLSTransportStateConnected indicates that DTLS has completed
// negotiation of a secure connection and verified the remote fingerprint.
DTLSTransportStateConnected
// DTLSTransportStateClosed indicates that the transport has been closed
// intentionally as the result of receipt of a close_notify alert, or
// calling close().
DTLSTransportStateClosed
// DTLSTransportStateFailed indicates that the transport has failed as
// the result of an error (such as receipt of an error alert or failure to
// validate the remote fingerprint).
DTLSTransportStateFailed
)
// This is done this way because of a linter.
const (
dtlsTransportStateNewStr = "new"
dtlsTransportStateConnectingStr = "connecting"
dtlsTransportStateConnectedStr = "connected"
dtlsTransportStateClosedStr = "closed"
dtlsTransportStateFailedStr = "failed"
)
func newDTLSTransportState(raw string) DTLSTransportState {
switch raw {
case dtlsTransportStateNewStr:
return DTLSTransportStateNew
case dtlsTransportStateConnectingStr:
return DTLSTransportStateConnecting
case dtlsTransportStateConnectedStr:
return DTLSTransportStateConnected
case dtlsTransportStateClosedStr:
return DTLSTransportStateClosed
case dtlsTransportStateFailedStr:
return DTLSTransportStateFailed
default:
return DTLSTransportStateUnknown
}
}
func (t DTLSTransportState) String() string {
switch t {
case DTLSTransportStateNew:
return dtlsTransportStateNewStr
case DTLSTransportStateConnecting:
return dtlsTransportStateConnectingStr
case DTLSTransportStateConnected:
return dtlsTransportStateConnectedStr
case DTLSTransportStateClosed:
return dtlsTransportStateClosedStr
case DTLSTransportStateFailed:
return dtlsTransportStateFailedStr
default:
return ErrUnknownType.Error()
}
}
// MarshalText implements encoding.TextMarshaler.
func (t DTLSTransportState) MarshalText() ([]byte, error) {
return []byte(t.String()), nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (t *DTLSTransportState) UnmarshalText(b []byte) error {
*t = newDTLSTransportState(string(b))
return nil
}

View File

@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewDTLSTransportState(t *testing.T) {
testCases := []struct {
stateString string
expectedState DTLSTransportState
}{
{ErrUnknownType.Error(), DTLSTransportStateUnknown},
{"new", DTLSTransportStateNew},
{"connecting", DTLSTransportStateConnecting},
{"connected", DTLSTransportStateConnected},
{"closed", DTLSTransportStateClosed},
{"failed", DTLSTransportStateFailed},
}
for i, testCase := range testCases {
assert.Equal(t,
testCase.expectedState,
newDTLSTransportState(testCase.stateString),
"testCase: %d %v", i, testCase,
)
}
}
func TestDTLSTransportState_String(t *testing.T) {
testCases := []struct {
state DTLSTransportState
expectedString string
}{
{DTLSTransportStateUnknown, ErrUnknownType.Error()},
{DTLSTransportStateNew, "new"},
{DTLSTransportStateConnecting, "connecting"},
{DTLSTransportStateConnected, "connected"},
{DTLSTransportStateClosed, "closed"},
{DTLSTransportStateFailed, "failed"},
}
for i, testCase := range testCases {
assert.Equal(t,
testCase.expectedString,
testCase.state.String(),
"testCase: %d %v", i, testCase,
)
}
}

16
e2e/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
FROM golang:1.27-alpine
RUN apk add --no-cache \
chromium \
chromium-chromedriver \
git
ENV CGO_ENABLED=0
COPY . /go/src/github.com/pion/webrtc
WORKDIR /go/src/github.com/pion/webrtc/e2e
CMD ["go", "test", "-tags=e2e", "-v", "."]

358
e2e/e2e_test.go Normal file
View File

@@ -0,0 +1,358 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build e2e
// +build e2e
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/sclevine/agouti"
)
var silentOpusFrame = []byte{0xf8, 0xff, 0xfe} // 20ms, 8kHz, mono
var drivers = map[string]func() *agouti.WebDriver{
"Chrome": func() *agouti.WebDriver {
return agouti.ChromeDriver(
agouti.ChromeOptions("args", []string{
"--headless",
"--disable-gpu",
"--no-sandbox",
}),
agouti.Desired(agouti.Capabilities{
"loggingPrefs": map[string]string{
"browser": "INFO",
},
}),
)
},
}
func TestE2E_Audio(t *testing.T) {
for name, d := range drivers {
driver := d()
t.Run(name, func(t *testing.T) {
if err := driver.Start(); err != nil {
t.Fatalf("Failed to start WebDriver: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer func() {
cancel()
time.Sleep(50 * time.Millisecond)
_ = driver.Stop()
}()
page, errPage := driver.NewPage()
if errPage != nil {
t.Fatalf("Failed to open page: %v", errPage)
}
if err := page.SetPageLoad(1000); err != nil {
t.Fatalf("Failed to load page: %v", err)
}
if err := page.SetImplicitWait(1000); err != nil {
t.Fatalf("Failed to set wait: %v", err)
}
chStarted := make(chan struct{})
chSDP := make(chan *webrtc.SessionDescription)
chStats := make(chan stats)
go logParseLoop(ctx, t, page, chStarted, chSDP, chStats)
pwd, errPwd := os.Getwd()
if errPwd != nil {
t.Fatalf("Failed to get working directory: %v", errPwd)
}
if err := page.Navigate(
fmt.Sprintf("file://%s/test.html", pwd),
); err != nil {
t.Fatalf("Failed to navigate: %v", err)
}
sdp := <-chSDP
pc, answer, track, errTrack := createTrack(*sdp)
if errTrack != nil {
t.Fatalf("Failed to create track: %v", errTrack)
}
defer func() {
_ = pc.Close()
}()
answerBytes, errAnsSDP := json.Marshal(answer)
if errAnsSDP != nil {
t.Fatalf("Failed to marshal SDP: %v", errAnsSDP)
}
var result string
if err := page.RunScript(
"pc.setRemoteDescription(JSON.parse(answer))",
map[string]any{"answer": string(answerBytes)},
&result,
); err != nil {
t.Fatalf("Failed to run script to set SDP: %v", err)
}
go func() {
for {
if err := track.WriteSample(
media.Sample{Data: silentOpusFrame, Duration: time.Millisecond * 20},
); err != nil {
t.Errorf("Failed to WriteSample: %v", err)
return
}
select {
case <-time.After(20 * time.Millisecond):
case <-ctx.Done():
return
}
}
}()
select {
case <-chStarted:
case <-time.After(5 * time.Second):
t.Fatal("Timeout")
}
<-chStats
var packetReceived [2]int
for i := 0; i < 2; i++ {
select {
case stat := <-chStats:
for _, s := range stat {
if s.Type != "inbound-rtp" {
continue
}
if s.Kind != "audio" {
t.Errorf("Unused track stat received: %+v", s)
continue
}
packetReceived[i] = s.PacketsReceived
}
case <-time.After(5 * time.Second):
t.Fatal("Timeout")
}
}
packetsPerSecond := packetReceived[1] - packetReceived[0]
if packetsPerSecond < 45 || 55 < packetsPerSecond {
t.Errorf("Number of OPUS packets is expected to be: 50/second, got: %d/second", packetsPerSecond)
}
})
}
}
func TestE2E_DataChannel(t *testing.T) {
for name, d := range drivers {
driver := d()
t.Run(name, func(t *testing.T) {
if err := driver.Start(); err != nil {
t.Fatalf("Failed to start WebDriver: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer func() {
cancel()
time.Sleep(50 * time.Millisecond)
_ = driver.Stop()
}()
page, errPage := driver.NewPage()
if errPage != nil {
t.Fatalf("Failed to open page: %v", errPage)
}
if err := page.SetPageLoad(1000); err != nil {
t.Fatalf("Failed to load page: %v", err)
}
if err := page.SetImplicitWait(1000); err != nil {
t.Fatalf("Failed to set wait: %v", err)
}
chStarted := make(chan struct{})
chSDP := make(chan *webrtc.SessionDescription)
go logParseLoop(ctx, t, page, chStarted, chSDP, nil)
pwd, errPwd := os.Getwd()
if errPwd != nil {
t.Fatalf("Failed to get working directory: %v", errPwd)
}
if err := page.Navigate(
fmt.Sprintf("file://%s/test.html", pwd),
); err != nil {
t.Fatalf("Failed to navigate: %v", err)
}
sdp := <-chSDP
pc, errPc := webrtc.NewPeerConnection(webrtc.Configuration{})
if errPc != nil {
t.Fatalf("Failed to create peer connection: %v", errPc)
}
defer func() {
_ = pc.Close()
}()
chValid := make(chan struct{})
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
dc.OnOpen(func() {
// Ping
if err := dc.SendText("hello world"); err != nil {
t.Errorf("Failed to send data: %v", err)
}
})
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
// Pong
if string(msg.Data) != "HELLO WORLD" {
t.Errorf("expected message from browser: HELLO WORLD, got: %s", string(msg.Data))
} else {
chValid <- struct{}{}
}
})
})
if err := pc.SetRemoteDescription(*sdp); err != nil {
t.Fatalf("Failed to set remote description: %v", err)
}
answer, errAns := pc.CreateAnswer(nil)
if errAns != nil {
t.Fatalf("Failed to create answer: %v", errAns)
}
if err := pc.SetLocalDescription(answer); err != nil {
t.Fatalf("Failed to set local description: %v", err)
}
answerBytes, errAnsSDP := json.Marshal(answer)
if errAnsSDP != nil {
t.Fatalf("Failed to marshal SDP: %v", errAnsSDP)
}
var result string
if err := page.RunScript(
"pc.setRemoteDescription(JSON.parse(answer))",
map[string]any{"answer": string(answerBytes)},
&result,
); err != nil {
t.Fatalf("Failed to run script to set SDP: %v", err)
}
select {
case <-chStarted:
case <-time.After(5 * time.Second):
t.Fatal("Timeout")
}
select {
case <-chValid:
case <-time.After(5 * time.Second):
t.Fatal("Timeout")
}
})
}
}
type stats []struct {
Kind string `json:"kind"`
Type string `json:"type"`
PacketsReceived int `json:"packetsReceived"`
}
func logParseLoop(ctx context.Context, t *testing.T, page *agouti.Page, chStarted chan struct{}, chSDP chan *webrtc.SessionDescription, chStats chan stats) {
for {
select {
case <-time.After(time.Second):
case <-ctx.Done():
return
}
logs, errLog := page.ReadNewLogs("browser")
if errLog != nil {
t.Errorf("Failed to read log: %v", errLog)
return
}
for _, log := range logs {
k, v, ok := parseLog(log)
if !ok {
t.Log(log.Message)
continue
}
switch k {
case "connection":
switch v {
case "connected":
close(chStarted)
case "failed":
t.Error("Browser reported connection failed")
return
}
case "sdp":
sdp := &webrtc.SessionDescription{}
if err := json.Unmarshal([]byte(v), sdp); err != nil {
t.Errorf("Failed to unmarshal SDP: %v", err)
return
}
chSDP <- sdp
case "stats":
if chStats == nil {
break
}
s := &stats{}
if err := json.Unmarshal([]byte(v), &s); err != nil {
t.Errorf("Failed to parse log: %v", err)
break
}
select {
case chStats <- *s:
case <-time.After(10 * time.Millisecond):
}
default:
t.Log(log.Message)
}
}
}
}
func parseLog(log agouti.Log) (string, string, bool) {
l := strings.SplitN(log.Message, " ", 4)
if len(l) != 4 {
return "", "", false
}
k, err1 := strconv.Unquote(l[2])
if err1 != nil {
return "", "", false
}
v, err2 := strconv.Unquote(l[3])
if err2 != nil {
return "", "", false
}
return k, v, true
}
func createTrack(offer webrtc.SessionDescription) (*webrtc.PeerConnection, *webrtc.SessionDescription, *webrtc.TrackLocalStaticSample, error) {
pc, errPc := webrtc.NewPeerConnection(webrtc.Configuration{})
if errPc != nil {
return nil, nil, nil, errPc
}
track, errTrack := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion")
if errTrack != nil {
return nil, nil, nil, errTrack
}
if _, err := pc.AddTrack(track); err != nil {
return nil, nil, nil, err
}
if err := pc.SetRemoteDescription(offer); err != nil {
return nil, nil, nil, err
}
answer, errAns := pc.CreateAnswer(nil)
if errAns != nil {
return nil, nil, nil, errAns
}
if err := pc.SetLocalDescription(answer); err != nil {
return nil, nil, nil, err
}
return pc, &answer, track, nil
}

45
e2e/test.html Normal file
View File

@@ -0,0 +1,45 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<div id="media"></div>
<script>
const pc = new RTCPeerConnection()
pc.ontrack = event => {
if (event.track.kind === 'audio') {
var el = document.createElement(event.track.kind)
el.srcObject = new MediaStream(event.streams[0].getAudioTracks())
document.getElementById('media').appendChild(el)
}
}
pc.oniceconnectionstatechange = event => {
console.log("connection", pc.iceConnectionState)
if (pc.iceConnectionState == 'connected') {
setInterval(statsReport, 1000)
}
}
pc.onicecandidate = event => {
if (event.candidate === null) {
console.log("sdp", JSON.stringify(pc.localDescription))
}
}
pc.addTransceiver('audio', {'direction': 'recvonly'})
const dc = pc.createDataChannel("upper")
dc.onmessage = event => {
dc.send(event.data.toUpperCase())
}
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(console.log)
const statsReport = async () => {
const stats = await pc.getStats()
var data = []
await stats.forEach(item => {
data.push(item)
})
console.log("stats", JSON.stringify(data))
}
</script>

296
errors.go Normal file
View File

@@ -0,0 +1,296 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
package webrtc
import (
"errors"
)
var (
// ErrUnknownType indicates an error with Unknown info.
ErrUnknownType = errors.New("unknown")
// ErrConnectionClosed indicates an operation executed after connection
// has already been closed.
ErrConnectionClosed = errors.New("connection closed")
// ErrDataChannelNotOpen indicates an operation executed when the data
// channel is not (yet) open.
ErrDataChannelNotOpen = errors.New("data channel not open")
// ErrCertificateExpired indicates that an x509 certificate has expired.
ErrCertificateExpired = errors.New("x509Cert expired")
// ErrNoTurnCredentials indicates that a TURN server URL was provided
// without required credentials.
ErrNoTurnCredentials = errors.New("turn server credentials required")
// ErrTurnCredentials indicates that provided TURN credentials are partial
// or malformed.
ErrTurnCredentials = errors.New("invalid turn server credentials")
// ErrExistingTrack indicates that a track already exists.
ErrExistingTrack = errors.New("track already exists")
// ErrPrivateKeyType indicates that a particular private key encryption
// chosen to generate a certificate is not supported.
ErrPrivateKeyType = errors.New("private key type not supported")
// ErrModifyingPeerIdentity indicates that an attempt to modify
// PeerIdentity was made after PeerConnection has been initialized.
ErrModifyingPeerIdentity = errors.New("peerIdentity cannot be modified")
// ErrModifyingCertificates indicates that an attempt to modify
// Certificates was made after PeerConnection has been initialized.
ErrModifyingCertificates = errors.New("certificates cannot be modified")
// ErrModifyingBundlePolicy indicates that an attempt to modify
// BundlePolicy was made after PeerConnection has been initialized.
ErrModifyingBundlePolicy = errors.New("bundle policy cannot be modified")
// ErrModifyingRTCPMuxPolicy indicates that an attempt to modify
// RTCPMuxPolicy was made after PeerConnection has been initialized.
ErrModifyingRTCPMuxPolicy = errors.New("rtcp mux policy cannot be modified")
// ErrModifyingICECandidatePoolSize indicates that an attempt to modify
// ICECandidatePoolSize was made after PeerConnection has been initialized.
ErrModifyingICECandidatePoolSize = errors.New("ice candidate pool size cannot be modified")
// ErrStringSizeLimit indicates that the character size limit of string is
// exceeded. The limit is hardcoded to 65535 according to specifications.
ErrStringSizeLimit = errors.New("data channel label exceeds size limit")
// ErrMaxDataChannelID indicates that the maximum number ID that could be
// specified for a data channel has been exceeded.
ErrMaxDataChannelID = errors.New("maximum number ID for datachannel specified")
// ErrNegotiatedWithoutID indicates that an attempt to create a data channel
// was made while setting the negotiated option to true without providing
// the negotiated channel ID.
ErrNegotiatedWithoutID = errors.New("negotiated set without channel id")
// ErrRetransmitsOrPacketLifeTime indicates that an attempt to create a data
// channel was made with both options MaxPacketLifeTime and MaxRetransmits
// set together. Such configuration is not supported by the specification
// and is mutually exclusive.
ErrRetransmitsOrPacketLifeTime = errors.New("both MaxPacketLifeTime and MaxRetransmits was set")
// ErrCodecNotFound is returned when a codec search to the Media Engine fails.
ErrCodecNotFound = errors.New("codec not found")
// ErrNoRemoteDescription indicates that an operation was rejected because
// the remote description is not set.
ErrNoRemoteDescription = errors.New("remote description is not set")
// ErrIncorrectSDPSemantics indicates that the PeerConnection was configured to
// generate SDP Answers with different SDP Semantics than the received Offer.
ErrIncorrectSDPSemantics = errors.New("remote SessionDescription semantics does not match configuration")
// ErrIncorrectSignalingState indicates that the signaling state of PeerConnection is not correct.
ErrIncorrectSignalingState = errors.New("operation can not be run in current signaling state")
// ErrProtocolTooLarge indicates that value given for a DataChannelInit protocol is
// longer then 65535 bytes.
ErrProtocolTooLarge = errors.New("protocol is larger then 65535 bytes")
// ErrSenderNotCreatedByConnection indicates RemoveTrack was called with a RtpSender not created
// by this PeerConnection.
ErrSenderNotCreatedByConnection = errors.New("RtpSender not created by this PeerConnection")
// ErrSessionDescriptionNoFingerprint indicates SetRemoteDescription was called with a SessionDescription that has no
// fingerprint.
ErrSessionDescriptionNoFingerprint = errors.New("SetRemoteDescription called with no fingerprint")
// ErrSessionDescriptionInvalidFingerprint indicates SetRemoteDescription was called with a SessionDescription that
// has an invalid fingerprint.
ErrSessionDescriptionInvalidFingerprint = errors.New("SetRemoteDescription called with an invalid fingerprint")
// ErrSessionDescriptionConflictingFingerprints indicates SetRemoteDescription was called with a SessionDescription
// that has an conflicting fingerprints.
ErrSessionDescriptionConflictingFingerprints = errors.New(
"SetRemoteDescription called with multiple conflicting fingerprint",
)
// ErrSessionDescriptionMissingIceUfrag indicates SetRemoteDescription was called with a SessionDescription that
// is missing an ice-ufrag value.
ErrSessionDescriptionMissingIceUfrag = errors.New("SetRemoteDescription called with no ice-ufrag")
// ErrSessionDescriptionMissingIcePwd indicates SetRemoteDescription was called with a SessionDescription that
// is missing an ice-pwd value.
ErrSessionDescriptionMissingIcePwd = errors.New("SetRemoteDescription called with no ice-pwd")
// ErrSessionDescriptionConflictingIceUfrag indicates SetRemoteDescription was called with a SessionDescription
// that contains multiple conflicting ice-ufrag values.
ErrSessionDescriptionConflictingIceUfrag = errors.New(
"SetRemoteDescription called with multiple conflicting ice-ufrag values",
)
// ErrSessionDescriptionConflictingIcePwd indicates SetRemoteDescription was called with a SessionDescription
// that contains multiple conflicting ice-pwd values.
ErrSessionDescriptionConflictingIcePwd = errors.New(
"SetRemoteDescription called with multiple conflicting ice-pwd values",
)
// ErrNoSRTPProtectionProfile indicates that the DTLS handshake completed and no SRTP Protection Profile was chosen.
ErrNoSRTPProtectionProfile = errors.New("DTLS Handshake completed and no SRTP Protection Profile was chosen")
// ErrFailedToGenerateCertificateFingerprint indicates that we failed to generate the fingerprint
// used for comparing certificates.
ErrFailedToGenerateCertificateFingerprint = errors.New("failed to generate certificate fingerprint")
// ErrNoCodecsAvailable indicates that operation isn't possible because the MediaEngine has no codecs available.
ErrNoCodecsAvailable = errors.New("operation failed no codecs are available")
// ErrUnsupportedCodec indicates the remote peer doesn't support the requested codec.
ErrUnsupportedCodec = errors.New("unable to start track, codec is not supported by remote")
// ErrSenderWithNoCodecs indicates that a RTPSender was created without any codecs. To send media the MediaEngine
// needs at least one configured codec.
ErrSenderWithNoCodecs = errors.New("unable to populate media section, RTPSender created with no codecs")
// ErrCodecAlreadyRegistered indicates that a codec has already been registered for the same payload type.
ErrCodecAlreadyRegistered = errors.New("codec already registered for same payload type")
// ErrRTPSenderNewTrackHasIncorrectKind indicates that the new track is of a different kind than the previous/original.
ErrRTPSenderNewTrackHasIncorrectKind = errors.New("new track must be of the same kind as previous")
// ErrRTPSenderNewTrackHasIncorrectEnvelope indicates that the new track has a different envelope
// than the previous/original.
ErrRTPSenderNewTrackHasIncorrectEnvelope = errors.New("new track must have the same envelope as previous")
// ErrUnbindFailed indicates that a TrackLocal was not able to be unbind.
ErrUnbindFailed = errors.New("failed to unbind TrackLocal from PeerConnection")
// ErrNoPayloaderForCodec indicates that the requested codec does not have a payloader.
ErrNoPayloaderForCodec = errors.New("the requested codec does not have a payloader")
// ErrRegisterHeaderExtensionInvalidDirection indicates that a extension was
// registered with a direction besides `sendonly` or `recvonly`.
ErrRegisterHeaderExtensionInvalidDirection = errors.New(
"a header extension must be registered as 'recvonly', 'sendonly' or both",
)
// ErrSimulcastProbeOverflow indicates that too many Simulcast probe streams are in flight
// and the requested SSRC was ignored.
ErrSimulcastProbeOverflow = errors.New("simulcast probe limit has been reached, new SSRC has been discarded")
// ErrSDPUnmarshalling indicates that the SDP could not be unmarshalled.
ErrSDPUnmarshalling = errors.New("failed to unmarshal SDP")
errDetachNotEnabled = errors.New("enable detaching by calling webrtc.DetachDataChannels()")
errDetachBeforeOpened = errors.New("datachannel not opened yet, try calling Detach from OnOpen")
errDtlsTransportNotStarted = errors.New("the DTLS transport has not started yet")
errDtlsKeyExtractionFailed = errors.New("failed extracting keys from DTLS for SRTP")
errFailedToStartSRTP = errors.New("failed to start SRTP")
errFailedToStartSRTCP = errors.New("failed to start SRTCP")
errInvalidDTLSStart = errors.New("attempted to start DTLSTransport that is not in new state")
errNoRemoteCertificate = errors.New("peer didn't provide certificate via DTLS")
errIdentityProviderNotImplemented = errors.New("identity provider is not implemented")
errNoMatchingCertificateFingerprint = errors.New("remote certificate does not match any fingerprint")
errICEConnectionNotStarted = errors.New("ICE connection not started")
errICECandidateTypeUnknown = errors.New("unknown candidate type")
errICEInvalidConvertCandidateType = errors.New(
"cannot convert ice.CandidateType into webrtc.ICECandidateType, invalid type",
)
errICEAgentNotExist = errors.New("ICEAgent does not exist")
errICECandiatesCoversionFailed = errors.New("unable to convert ICE candidates to ICECandidates")
errICERoleUnknown = errors.New("unknown ICE Role")
errICEProtocolUnknown = errors.New("unknown protocol")
errICEGathererNotStarted = errors.New("gatherer not started")
errAddressRewriteWithNAT1To1 = errors.New("address rewrite rules cannot be combined with NAT1To1IPs")
errNetworkTypeUnknown = errors.New("unknown network type")
errSDPDoesNotMatchOffer = errors.New("new sdp does not match previous offer")
errSDPDoesNotMatchAnswer = errors.New("new sdp does not match previous answer")
errPeerConnSDPTypeInvalidValue = errors.New(
"provided value is not a valid enum value of type SDPType",
)
errPeerConnStateChangeInvalid = errors.New("invalid state change op")
errPeerConnStateChangeUnhandled = errors.New("unhandled state change op")
errPeerConnSDPTypeInvalidValueSetLocalDescription = errors.New("invalid SDP type supplied to SetLocalDescription()")
errPeerConnRemoteDescriptionWithoutMidValue = errors.New(
"remoteDescription contained media section without mid value",
)
errPeerConnRemoteDescriptionNil = errors.New("remoteDescription has not been set yet")
errMediaSectionHasExplictSSRCAttribute = errors.New("media section has an explicit SSRC")
errPeerConnRemoteSSRCAddTransceiver = errors.New("could not add transceiver for remote SSRC")
errPeerConnSimulcastMidRTPExtensionRequired = errors.New("mid RTP Extensions required for Simulcast")
errPeerConnSimulcastStreamIDRTPExtensionRequired = errors.New("stream id RTP Extensions required for Simulcast")
errPeerConnSimulcastIncomingSSRCFailed = errors.New("incoming SSRC failed Simulcast probing")
errPeerConnAddTransceiverFromKindOnlyAcceptsOne = errors.New(
"AddTransceiverFromKind only accepts one RTPTransceiverInit",
)
errPeerConnAddTransceiverFromTrackOnlyAcceptsOne = errors.New(
"AddTransceiverFromTrack only accepts one RTPTransceiverInit",
)
errPeerConnAddTransceiverFromKindSupport = errors.New(
"AddTransceiverFromKind currently only supports recvonly",
)
errPeerConnAddTransceiverFromTrackSupport = errors.New(
"AddTransceiverFromTrack currently only supports sendonly and sendrecv",
)
errPeerConnSetIdentityProviderNotImplemented = errors.New("TODO SetIdentityProvider")
errPeerConnWriteRTCPOpenWriteStream = errors.New("WriteRTCP failed to open WriteStream")
errPeerConnTranscieverMidNil = errors.New("cannot find transceiver with mid")
errPeerConnEarlyMediaWithoutAnswer = errors.New(
"cannot process early media without SDP answer," +
"use SettingEngine.SetHandleUndeclaredSSRCWithoutAnswer(true) to process without answer",
)
errRTPReceiverDTLSTransportNil = errors.New("DTLSTransport must not be nil")
errRTPReceiverReceiveAlreadyCalled = errors.New("Receive has already been called")
errRTPReceiverWithSSRCTrackStreamNotFound = errors.New("unable to find stream for Track with SSRC")
errRTPReceiverForRIDTrackStreamNotFound = errors.New("no trackStreams found for RID")
errRTPSenderTrackNil = errors.New("Track must not be nil")
errRTPSenderDTLSTransportNil = errors.New("DTLSTransport must not be nil")
errRTPSenderSendAlreadyCalled = errors.New("Send has already been called")
errRTPSenderSendNotCalled = errors.New("Send has not been called")
errRTPSenderStopped = errors.New("Sender has already been stopped")
errRTPSenderTrackRemoved = errors.New("Sender Track has been removed or replaced to nil")
errRTPSenderRidNil = errors.New("Sender cannot add encoding as rid is empty")
errRTPSenderNoBaseEncoding = errors.New("Sender cannot add encoding as there is no base track")
errRTPSenderBaseEncodingMismatch = errors.New("Sender cannot add encoding as provided track does not match base track")
errRTPSenderRIDCollision = errors.New("Sender cannot encoding due to RID collision")
errRTPSenderNoTrackForRID = errors.New("Sender does not have track for RID")
errRTPTransceiverCannotChangeMid = errors.New("cannot change transceiver mid")
errRTPTransceiverSetSendingInvalidState = errors.New("invalid state change in RTPTransceiver.setSending")
errRTPTransceiverCodecUnsupported = errors.New("unsupported codec type by this transceiver")
errSCTPTransportDTLS = errors.New("DTLS not established")
errSDPZeroTransceivers = errors.New("addTransceiverSDP() called with 0 transceivers")
errSDPMediaSectionMediaDataChanInvalid = errors.New("invalid Media Section. Media + DataChannel both enabled")
errSDPMediaSectionMultipleTrackInvalid = errors.New(
"invalid Media Section. Can not have multiple tracks in one MediaSection in UnifiedPlan",
)
errSettingEngineSetAnsweringDTLSRole = errors.New("SetAnsweringDTLSRole must DTLSRoleClient or DTLSRoleServer")
errSignalingStateCannotRollback = errors.New("can't rollback from stable state")
errSignalingStateProposedTransitionInvalid = errors.New("invalid proposed signaling state transition")
errStatsICECandidateStateInvalid = errors.New(
"cannot convert to StatsICECandidatePairStateSucceeded invalid ice candidate state",
)
errICECandidatePoolSizeTooLarge = errors.New("ice candidate pool size greater than 1 is not supported")
errInvalidICECredentialTypeString = errors.New("invalid ICECredentialType")
errInvalidICEServer = errors.New("invalid ICEServer")
errICETransportNotInNew = errors.New("ICETransport can only be called in ICETransportStateNew")
errICETransportClosed = errors.New("ICETransport closed")
errCertificatePEMMultipleCert = errors.New("failed parsing certificate, more than 1 CERTIFICATE block in pems")
errCertificatePEMMultiplePriv = errors.New("failed parsing certificate, more than 1 PRIVATE KEY block in pems")
errCertificatePEMMissing = errors.New("failed parsing certificate, pems must contain both a CERTIFICATE block and a PRIVATE KEY block") // nolint: lll
errRTPTooShort = errors.New("not long enough to be a RTP Packet")
errExcessiveRetries = errors.New("excessive retries in CreateOffer")
)

70
examples/README.md Normal file
View File

@@ -0,0 +1,70 @@
<h1 align="center">
Examples
</h1>
We've built an extensive collection of examples covering common use-cases. You can modify and extend these examples to get started quickly.
For more full featured examples that use 3rd party libraries see our **[example-webrtc-applications](https://github.com/pion/example-webrtc-applications)** repo.
### Overview
#### Media API
* [Reflect](reflect): The reflect example demonstrates how to have Pion send back to the user exactly what it receives using the same PeerConnection.
* [Play from Disk](play-from-disk): The play-from-disk example demonstrates how to send video to your browser from a file saved to disk.
* [Play from Disk Renegotiation](play-from-disk-renegotiation): The play-from-disk-renegotiation example is an extension of the play-from-disk example, but demonstrates how you can add/remove video tracks from an already negotiated PeerConnection.
* [Insertable Streams](insertable-streams): The insertable-streams example demonstrates how Pion can be used to send E2E encrypted video and decrypt via insertable streams in the browser.
* [Save to Disk](save-to-disk): The save-to-disk example shows how to record your webcam and save the footage to disk on the server side.
* [Broadcast](broadcast): The broadcast example demonstrates how to broadcast a video to multiple peers. A broadcaster uploads the video once and the server forwards it to all other peers.
* [RTP Forwarder](rtp-forwarder): The rtp-forwarder example demonstrates how to forward your audio/video streams using RTP.
* [RTP to WebRTC](rtp-to-webrtc): The rtp-to-webrtc example demonstrates how to take RTP packets sent to a Pion process into your browser.
* [Simulcast](simulcast): The simulcast example demonstrates how to accept and demux 1 Track that contains 3 Simulcast streams. It then returns the media as 3 independent Tracks back to the sender.
* [Swap Tracks](swap-tracks): The swap-tracks example demonstrates deeper usage of the Pion Media API. The server accepts 3 media streams, and then dynamically routes them back as a single stream to the user.
* [RTCP Processing](rtcp-processing) The rtcp-processing example demonstrates Pion's RTCP APIs. This allow access to media statistics and control information.
* [Quick Switch](quick-switch) Use WebRTC to switch quickly between video feeds. Similiar to swap-tracks, but user controls when video is switched and uses static files.
#### Data Channel API
* [Data Channels](data-channels): The data-channels example shows how you can send/recv DataChannel messages from a web browser.
* [Data Channels Detach](data-channels-detach): The data-channels-detach example shows how you can send/recv DataChannel messages using the underlying DataChannel implementation directly. This provides a more idiomatic way of interacting with Data Channels.
* [Data Channels Flow Control](data-channels-flow-control): Example data-channels-flow-control shows how to use the DataChannel API efficiently. You can measure the amount the rate at which the remote peer is receiving data, and structure your application accordingly.
* [ORTC](ortc): Example ortc shows how you an use the ORTC API for DataChannel communication.
* [Pion to Pion](pion-to-pion): Example pion-to-pion is an example of two pion instances communicating directly! It therefore has no corresponding web page.
#### Miscellaneous
* [Custom Logger](custom-logger) The custom-logger demonstrates how the user can override the logging and process messages instead of printing to stdout. It has no corresponding web page.
* [ICE Restart](ice-restart) Example ice-restart demonstrates how a WebRTC connection can roam between networks. This example restarts ICE in a loop and prints the new addresses it uses each time.
* [ICE Single Port](ice-single-port) Example ice-single-port demonstrates how multiple WebRTC connections can be served from a single port. By default Pion listens on a new port for every PeerConnection. Pion can be configured to use a single port for multiple connections.
* [ICE TCP](ice-tcp) Example ice-tcp demonstrates how a WebRTC connection can be made over TCP instead of UDP. By default Pion only does UDP. Pion can be configured to use a TCP port, and this TCP port can be used for many connections.
* [ICE Proxy](ice-proxy) Example ice-proxy demonstrates how to use a proxy for TURN connections.
* [Trickle ICE](trickle-ice) Example trickle-ice example demonstrates Pion WebRTC's Trickle ICE APIs. This is important to use since it allows ICE Gathering and Connecting to happen concurrently.
* [VNet](vnet) Example vnet demonstrates Pion's network virtualisation library. This example connects two PeerConnections over a virtual network and prints statistics about the data traveling over it.
### Usage
We've made it easy to run the browser based examples on your local machine.
1. Build and run the example server:
``` sh
git clone https://github.com/pion/webrtc.git webrtc
cd pion/webrtc/examples
go run examples.go
```
2. Browse to [localhost](http://localhost) to browse through the examples. Note that you can change the port of the server using the ``--address`` flag:
``` sh
go run examples.go --address localhost:8080
go run examples.go --address :8080 # listen on all available interfaces
```
### WebAssembly
Pion WebRTC can be used when compiled to WebAssembly, also known as WASM. In
this case the library will act as a wrapper around the JavaScript WebRTC API.
This allows you to use WebRTC from Go in both server and browser side code with
little to no changes
Some of our examples have support for WebAssembly. The same examples server documented above can be used to run the WebAssembly examples. However, you have to compile them first. This is done as follows:
1. If the example supports WebAssembly it will contain a `main.go` file under the `jsfiddle` folder.
2. Build this `main.go` file as follows:
```
GOOS=js GOARCH=wasm go build -o demo.wasm
```
3. Start the example server. Refer to the [usage](#usage) section for how you can build the example server.
4. Browse to [localhost](http://localhost). The page should now give you the option to run the example using the WebAssembly binary.

View File

@@ -0,0 +1,48 @@
# bandwidth-estimation-from-disk
bandwidth-estimation-from-disk demonstrates how to use Pion's Bandwidth Estimation APIs.
Pion provides multiple Bandwidth Estimators, but they all satisfy one interface. This interface
emits an int for how much bandwidth is available to send. It is then up to the sender to meet that number.
## Instructions
### Create IVF files named `high.ivf` `med.ivf` and `low.ivf`
```
ffmpeg -i $INPUT_FILE -g 30 -b:v .3M -s 320x240 low.ivf
ffmpeg -i $INPUT_FILE -g 30 -b:v 1M -s 858x480 med.ivf
ffmpeg -i $INPUT_FILE -g 30 -b:v 2.5M -s 1280x720 high.ivf
```
### Download bandwidth-estimation-from-disk
```
go install github.com/pion/webrtc/v4/examples/bandwidth-estimation-from-disk@latest
```
### Open bandwidth-estimation-from-disk example page
[jsfiddle.net](https://jsfiddle.net/a1cz42op/) you should see two text-areas, 'Start Session' button and 'Copy browser SessionDescription to clipboard'
### Run bandwidth-estimation-from-disk with your browsers Session Description as stdin
The `output.ivf` you created should be in the same directory as `bandwidth-estimation-from-disk`. In the jsfiddle press 'Copy browser Session Description to clipboard' or copy the base64 string manually.
Now use this value you just copied as the input to `bandwidth-estimation-from-disk`
#### Linux/macOS
Run `echo $BROWSER_SDP | bandwidth-estimation-from-disk`
#### Windows
1. Paste the SessionDescription into a file.
1. Run `bandwidth-estimation-from-disk < my_file`
### Input bandwidth-estimation-from-disk's Session Description into your browser
Copy the text that `bandwidth-estimation-from-disk` just emitted and copy into the second text area in the jsfiddle
### Hit 'Start Session' in jsfiddle, enjoy your video!
A video should start playing in your browser above the input boxes. When `bandwidth-estimation-from-disk` switches quality levels it will print the old and new file like so.
```
Switching from low.ivf to med.ivf
Switching from med.ivf to high.ivf
Switching from high.ivf to med.ivf
```
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,313 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// bandwidth-estimation-from-disk demonstrates how to use Pion's Bandwidth Estimation APIs.
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/cc"
"github.com/pion/interceptor/pkg/gcc"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
)
const (
lowFile = "low.ivf"
lowBitrate = 300_000
medFile = "med.ivf"
medBitrate = 1_000_000
highFile = "high.ivf"
highBitrate = 2_500_000
ivfHeaderSize = 32
)
func main() { //nolint:gocognit,cyclop,maintidx
qualityLevels := []struct {
fileName string
bitrate int
}{
{lowFile, lowBitrate},
{medFile, medBitrate},
{highFile, highBitrate},
}
currentQuality := 0
for _, level := range qualityLevels {
_, err := os.Stat(level.fileName)
if os.IsNotExist(err) {
panic(fmt.Sprintf("File %s was not found", level.fileName))
}
}
interceptorRegistry := &interceptor.Registry{}
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
panic(err)
}
// Create a Congestion Controller. This analyzes inbound and outbound data and provides
// suggestions on how much we should be sending.
//
// Passing `nil` means we use the default Estimation Algorithm which is Google Congestion Control.
// You can use the other ones that Pion provides, or write your own!
congestionController, err := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) {
return gcc.NewSendSideBWE(gcc.SendSideBWEInitialBitrate(lowBitrate))
})
if err != nil {
panic(err)
}
estimatorChan := make(chan cc.BandwidthEstimator, 1)
congestionController.OnNewPeerConnection(func(id string, estimator cc.BandwidthEstimator) { //nolint: revive
estimatorChan <- estimator
})
interceptorRegistry.Add(congestionController)
if err = webrtc.ConfigureTWCCHeaderExtensionSender(mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
if err = webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewAPI(
webrtc.WithInterceptorRegistry(interceptorRegistry), webrtc.WithMediaEngine(mediaEngine),
).NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
})
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Wait until our Bandwidth Estimator has been created
estimator := <-estimatorChan
// Create a video track
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8}, "video", "pion",
)
if err != nil {
panic(err)
}
rtpSender, err := peerConnection.AddTrack(videoTrack)
if err != nil {
panic(err)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
// 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("Connection State has changed %s \n", connectionState.String())
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
})
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
if err = peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
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
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Open a IVF file and start reading using our IVFReader
file, err := os.Open(qualityLevels[currentQuality].fileName)
if err != nil {
panic(err)
}
ivf, header, err := ivfreader.NewWith(file)
if err != nil {
panic(err)
}
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
//
// It is important to use a time.Ticker instead of time.Sleep because
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
ticker := time.NewTicker(
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
)
defer ticker.Stop()
frame := []byte{}
frameHeader := &ivfreader.IVFFrameHeader{}
currentTimestamp := uint64(0)
switchQualityLevel := func(newQualityLevel int) {
fmt.Printf(
"Switching from %s to %s \n",
qualityLevels[currentQuality].fileName,
qualityLevels[newQualityLevel].fileName,
)
currentQuality = newQualityLevel
ivf.ResetReader(setReaderFile(qualityLevels[currentQuality].fileName))
for {
if frame, frameHeader, err = ivf.ParseNextFrame(); err != nil {
break
} else if frameHeader.Timestamp >= currentTimestamp && frame[0]&0x1 == 0 {
break
}
}
}
for ; true; <-ticker.C {
targetBitrate := estimator.GetTargetBitrate()
switch {
// If current quality level is below target bitrate drop to level below
case currentQuality != 0 && targetBitrate < qualityLevels[currentQuality].bitrate:
switchQualityLevel(currentQuality - 1)
// If next quality level is above target bitrate move to next level
case len(qualityLevels) > (currentQuality+1) && targetBitrate > qualityLevels[currentQuality+1].bitrate:
switchQualityLevel(currentQuality + 1)
// Adjust outbound bandwidth for probing
default:
frame, frameHeader, err = ivf.ParseNextFrame()
}
switch {
// If we have reached the end of the file start again
case errors.Is(err, io.EOF):
ivf.ResetReader(setReaderFile(qualityLevels[currentQuality].fileName))
// No error write the video frame
case err == nil:
currentTimestamp = frameHeader.Timestamp
if err = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); err != nil {
panic(err)
}
// Error besides io.EOF that we dont know how to handle
default:
panic(err)
}
}
}
func setReaderFile(filename string) func(_ int64) io.Reader {
return func(_ int64) io.Reader {
file, err := os.Open(filename) // nolint
if err != nil {
panic(err)
}
if _, err = file.Seek(ivfHeaderSize, io.SeekStart); err != nil {
panic(err)
}
return file
}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,40 @@
# broadcast
broadcast is a Pion WebRTC application that demonstrates how to broadcast a video to many peers, while only requiring the broadcaster to upload once.
This could serve as the building block to building conferencing software, and other applications where publishers are bandwidth constrained.
## Instructions
### Download broadcast
```
go install github.com/pion/webrtc/v4/examples/broadcast@latest
```
### Open broadcast example page
[jsfiddle.net](https://jsfiddle.net/us4h58jx/) You should see two buttons `Publish a Broadcast` and `Join a Broadcast`
### Run Broadcast
#### Linux/macOS
Run `broadcast` OR run `main.go` in `github.com/pion/webrtc/examples/broadcast`
### Start a publisher
* Click `Publish a Broadcast`
* Press `Copy browser SDP to clipboard` or copy the `Browser base64 Session Description` string manually
* Run `curl localhost:8080 -d "$BROWSER_OFFER"`. `$BROWSER_OFFER` is the value you copied in the last step.
* The `broadcast` terminal application will respond with an answer, paste this into the second input field in your browser.
* Press `Start Session`
* The connection state will be printed in the terminal and under `logs` in the browser.
### Join the broadcast
* Click `Join a Broadcast`
* Copy the string in the first input labelled `Browser base64 Session Description`
* Run `curl localhost:8080 -d "$BROWSER_OFFER"`. `$BROWSER_OFFER` is the value you copied in the last step.
* The `broadcast` terminal application will respond with an answer, paste this into the second input field in your browser.
* Press `Start Session`
* The connection state will be printed in the terminal and under `logs` in the browser.
You can change the listening port using `-port 8011`
You can `Join the broadcast` as many times as you want. The `broadcast` Golang application is relaying all traffic, so your browser only has to upload once.
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,8 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: broadcast
description: Example of a broadcast using Pion WebRTC
authors:
- Sean DuBois

View File

@@ -0,0 +1,30 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<div id="signalingContainer" style="display: none">
Browser base64 Session Description<br />
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
<button onclick="window.copySDP()">
Copy browser SDP to clipboard
</button>
<br />
<br />
Golang base64 Session Description<br />
<textarea id="remoteSessionDescription"></textarea> <br/>
<button onclick="window.startSession()"> Start Session </button><br />
</div>
<br />
Video<br />
<video id="video1" width="160" height="120" autoplay muted></video> <br />
<button class="createSessionButton" onclick="window.createSession(true)"> Publish a Broadcast </button>
<button class="createSessionButton" onclick="window.createSession(false)"> Join a Broadcast </button><br />
<br />
Logs<br />
<div id="logs"></div>

View File

@@ -0,0 +1,82 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
const log = msg => {
document.getElementById('logs').innerHTML += msg + '<br>'
}
window.createSession = isPublisher => {
const pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
pc.onicecandidate = event => {
if (event.candidate === null) {
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
}
}
if (isPublisher) {
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
.then(stream => {
stream.getTracks().forEach(track => pc.addTrack(track, stream))
document.getElementById('video1').srcObject = stream
pc.createOffer()
.then(d => pc.setLocalDescription(d))
.catch(log)
}).catch(log)
} else {
pc.addTransceiver('video')
pc.createOffer()
.then(d => pc.setLocalDescription(d))
.catch(log)
pc.ontrack = function (event) {
const el = document.getElementById('video1')
el.srcObject = event.streams[0]
el.autoplay = true
el.controls = true
}
}
window.startSession = () => {
const sd = document.getElementById('remoteSessionDescription').value
if (sd === '') {
return alert('Session Description must not be empty')
}
try {
pc.setRemoteDescription(JSON.parse(atob(sd)))
} catch (e) {
alert(e)
}
}
window.copySDP = () => {
const browserSDP = document.getElementById('localSessionDescription')
browserSDP.focus()
browserSDP.select()
try {
const successful = document.execCommand('copy')
const msg = successful ? 'successful' : 'unsuccessful'
log('Copying SDP was ' + msg)
} catch (err) {
log('Unable to copy SDP ' + err)
}
}
const btns = document.getElementsByClassName('createSessionButton')
for (let i = 0; i < btns.length; i++) {
btns[i].style = 'display: none'
}
document.getElementById('signalingContainer').style = 'display: block'
}

242
examples/broadcast/main.go Normal file
View File

@@ -0,0 +1,242 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// broadcast demonstrates how to broadcast a video to many peers, while only requiring the broadcaster to upload once.
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"strconv"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/intervalpli"
"github.com/pion/webrtc/v4"
)
// nolint:gocognit, cyclop
func main() {
port := flag.Int("port", 8080, "http server port")
flag.Parse()
sdpChan := httpSDPServer(*port)
// Everything below is the Pion WebRTC API, thanks for using it ❤️.
offer := webrtc.SessionDescription{}
decode(<-sdpChan, &offer)
fmt.Println("")
peerConnectionConfig := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
panic(err)
}
// Create a InterceptorRegistry. This is the user configurable RTP/RTCP Pipeline.
// This provides NACKs, RTCP Reports and other features. If you use `webrtc.NewPeerConnection`
// this is enabled by default. If you are manually managing You MUST create a InterceptorRegistry
// for each PeerConnection.
interceptorRegistry := &interceptor.Registry{}
// Use the default set of Interceptors
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
// Register a intervalpli factory
// This interceptor sends a PLI every 3 seconds. A PLI causes a video keyframe to be generated by the sender.
// This makes our video seekable and more error resilent, but at a cost of lower picture quality and higher bitrates
// A real world application should process incoming RTCP packets from viewers and forward them to senders
intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
if err != nil {
panic(err)
}
interceptorRegistry.Add(intervalPliFactory)
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewAPI(
webrtc.WithMediaEngine(mediaEngine),
webrtc.WithInterceptorRegistry(interceptorRegistry),
).NewPeerConnection(peerConnectionConfig)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Allow us to receive 1 video track
if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo); err != nil {
panic(err)
}
localTrackChan := make(chan *webrtc.TrackLocalStaticRTP)
// Set a handler for when a new remote track starts, this just distributes all our packets
// to connected peers
peerConnection.OnTrack(func(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { //nolint: revive
// Create a local track, all our SFU clients will be fed via this track
localTrack, newTrackErr := webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, "video", "pion")
if newTrackErr != nil {
panic(newTrackErr)
}
localTrackChan <- localTrack
rtpBuf := make([]byte, 1400)
for {
i, _, readErr := remoteTrack.Read(rtpBuf)
if readErr != nil {
panic(readErr)
}
// ErrClosedPipe means we don't have any subscribers, this is ok if no peers have connected yet
if _, err = localTrack.Write(rtpBuf[:i]); err != nil && !errors.Is(err, io.ErrClosedPipe) {
panic(err)
}
}
})
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if 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
// Get the LocalDescription and take it to base64 so we can paste in browser
fmt.Println(encode(peerConnection.LocalDescription()))
localTrack := <-localTrackChan
for {
fmt.Println("")
fmt.Println("Curl an base64 SDP to start sendonly peer connection")
recvOnlyOffer := webrtc.SessionDescription{}
decode(<-sdpChan, &recvOnlyOffer)
// Create a new PeerConnection
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfig)
if err != nil {
panic(err)
}
rtpSender, err := peerConnection.AddTrack(localTrack)
if err != nil {
panic(err)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(recvOnlyOffer)
if err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete = webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if 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
// Get the LocalDescription and take it to base64 so we can paste in browser
fmt.Println(encode(peerConnection.LocalDescription()))
}
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}
// httpSDPServer starts a HTTP Server that consumes SDPs.
func httpSDPServer(port int) chan string {
sdpChan := make(chan string)
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
fmt.Fprintf(res, "done") //nolint: errcheck
sdpChan <- string(body)
})
go func() {
// nolint: gosec
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
}()
return sdpChan
}

View File

@@ -0,0 +1,56 @@
# custom-logger
`custom-logger` is an example demonstrating how to override the default logging behavior of the [Pion WebRTC](https://github.com/pion/webrtc) stack.
By default, Pion logs everything to `stdout`.
This example shows how to inject a **custom `LoggerFactory`** to handle logs from every subsystem (ICE, DTLS, SCTP, DataChannel...).
---
## Features
- Creates a **custom logger** that implements `logging.LeveledLogger`.
- Initializes two peer connections (`offerer` and `answerer`) locally.
- Establishes a WebRTC connection between them.
- Logs events from:
- `ICE` candidate gathering
- `DTLS` handshake
- `SCTP` and `DataChannel` setup
- Prints logs with clear prefixes like `customLogger Debug:`.
Ideal for:
- Integrate with external monitoring systems
- Store logs to files or databases
- Debug complex WebRTC flows in a structured way
---
## How to run
### 1. Install the example
```
go install github.com/pion/webrtc/v4/examples/custom-logger@latest
```
Make sure ```$(go env GOPATH)/bin ``` is in your ```PATH```.
You can add it to your PATH like this (zsh):
```
echo 'export PATH="$PATH:$(go env GOPATH)/bin"' >> ~/.zshrc
source ~/.zshrc
```
### 2.Run
`custom-logger` or `go run main.go`
## Example output
```
Creating logger for ice
Creating logger for dtls
Peer Connection State has changed: connected (answerer)
Peer Connection State has changed: connected (offerer)
customLogger Debug: Adding a new peer-reflexive candidate: 10.8.21.1:51196
```
You should see messages from our customLogger, as two PeerConnections start a session

View File

@@ -0,0 +1,184 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// custom-logger is an example of how the Pion API provides an customizable logging API
package main
import (
"fmt"
"os"
"github.com/pion/logging"
"github.com/pion/webrtc/v4"
)
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// customLogger satisfies the interface logging.LeveledLogger
// a logger is created per subsystem in Pion, so you can have custom
// behavior per subsystem (ICE, DTLS, SCTP...)
type customLogger struct{}
// Print all messages except trace.
func (c customLogger) Trace(string) {}
func (c customLogger) Tracef(string, ...any) {}
func (c customLogger) Debug(msg string) { fmt.Printf("customLogger Debug: %s\n", msg) }
func (c customLogger) Debugf(format string, args ...any) {
c.Debug(fmt.Sprintf(format, args...))
}
func (c customLogger) Info(msg string) { fmt.Printf("customLogger Info: %s\n", msg) }
func (c customLogger) Infof(format string, args ...any) {
c.Info(fmt.Sprintf(format, args...))
}
func (c customLogger) Warn(msg string) { fmt.Printf("customLogger Warn: %s\n", msg) }
func (c customLogger) Warnf(format string, args ...any) {
c.Warn(fmt.Sprintf(format, args...))
}
func (c customLogger) Error(msg string) { fmt.Printf("customLogger Error: %s\n", msg) }
func (c customLogger) Errorf(format string, args ...any) {
c.Error(fmt.Sprintf(format, args...))
}
// customLoggerFactory satisfies the interface logging.LoggerFactory
// This allows us to create different loggers per subsystem. So we can
// add custom behavior.
type customLoggerFactory struct{}
func (c customLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger {
fmt.Printf("Creating logger for %s \n", subsystem)
return customLogger{}
}
// nolint: cyclop
func main() {
// Create a new API with a custom logger
// This SettingEngine allows non-standard WebRTC behavior
s := webrtc.SettingEngine{
LoggerFactory: customLoggerFactory{},
}
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
// Create a new RTCPeerConnection
offerPeerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}
defer func() {
if cErr := offerPeerConnection.Close(); cErr != nil {
fmt.Printf("cannot close offerPeerConnection: %v\n", cErr)
}
}()
// We need a DataChannel so we can have ICE Candidates
if _, err = offerPeerConnection.CreateDataChannel("custom-logger", nil); err != nil {
panic(err)
}
// Create a new RTCPeerConnection
answerPeerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}
defer func() {
if cErr := answerPeerConnection.Close(); cErr != nil {
fmt.Printf("cannot close answerPeerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
offerPeerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s (offerer)\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
answerPeerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s (answerer)\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
})
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
// send it to the other peer
answerPeerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
if iceErr := offerPeerConnection.AddICECandidate(candidate.ToJSON()); iceErr != nil {
panic(iceErr)
}
}
})
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
// send it to the other peer
offerPeerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
if iceErr := answerPeerConnection.AddICECandidate(candidate.ToJSON()); iceErr != nil {
panic(iceErr)
}
}
})
// Create an offer for the other PeerConnection
offer, err := offerPeerConnection.CreateOffer(nil)
if err != nil {
panic(err)
}
// SetLocalDescription, needed before remote gets offer
if err = offerPeerConnection.SetLocalDescription(offer); err != nil {
panic(err)
}
// Take offer from remote, answerPeerConnection is now able to contact
// the other PeerConnection
if err = answerPeerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create an Answer to send back to our originating PeerConnection
answer, err := answerPeerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Set the answerer's LocalDescription
if err = answerPeerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// SetRemoteDescription on original PeerConnection, this finishes our signaling
// bother PeerConnections should be able to communicate with each other now
if err = offerPeerConnection.SetRemoteDescription(answer); err != nil {
panic(err)
}
// Block forever
select {}
}

View File

@@ -0,0 +1,30 @@
# data-channels-detach-create
data-channels-detach is an example that shows how you can detach a data channel. This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel). This allows you to interact with the data channel using a more idiomatic API based on the `io.ReadWriteCloser` interface.
The example is meant to be used with data-channels-detach. This demonstrates two Go Pion processes communicating directly.
## Run data-channels-detach-create and make an offer to data-channels-detach via stdin
```
go run data-channels-detach-create/*.go | go run data-channels-detach/*.go
```
## post the answer from data-channels-detach back to data-channels-detach-create
You will see a base64 SDP printed to your console. You now need to communicate this back to `data-channels-detach-create` this can be done via a HTTP endpoint
`curl localhost:8080/sdp -d "BASE_64_SDP"`
## Output
On sucess you will get output like the following
```
Peer Connection State has changed: connecting
(Long base64 SDP that you should POST)
Peer Connection State has changed: connected
New DataChannel 1374394845054
Data channel ''-'1374394845054' open.
Message from DataChannel: kvmWkjYodyQcIlv
Sending aMDnwlTfDYnfoUy
Sending htqQtnbvygZKlmy
Message from DataChannel: CMjZiNtsmIBpCaN
```

View File

@@ -0,0 +1,211 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// data-channels-detach is an example that shows how you can detach a data channel.
// This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel).
// This allows you to interact with the data channel using a more idiomatic API based on
// the `io.ReadWriteCloser` interface.
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
const messageSize = 15
func main() {
sdpChan := httpSDPServer(8080)
// Since this behavior diverges from the WebRTC API it has to be
// enabled using a settings engine. Mixing both detached and the
// OnMessage DataChannel API is not supported.
// Create a SettingEngine and enable Detach
s := webrtc.SettingEngine{}
s.DetachDataChannels()
// Create an API object with the engine
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection using the API object
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
dataChannel, err := peerConnection.CreateDataChannel("", nil)
if err != nil {
panic(err)
}
dataChannel.OnOpen(func() {
fmt.Printf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID())
// Detach the data channel
raw, dErr := dataChannel.Detach()
if dErr != nil {
panic(dErr)
}
// Handle reading from the data channel
go ReadLoop(raw)
// Handle writing to the data channel
go WriteLoop(raw)
})
// Create an offer to send to the browser
offer, err := peerConnection.CreateOffer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
if err = peerConnection.SetLocalDescription(offer); 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
// Output the offer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Wait for the answer to be submitted via HTTP
answer := webrtc.SessionDescription{}
decode(<-sdpChan, &answer)
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(answer)
if err != nil {
panic(err)
}
// Block forever
select {}
}
// ReadLoop shows how to read from the datachannel directly.
func ReadLoop(d io.Reader) {
for {
buffer := make([]byte, messageSize)
n, err := d.Read(buffer)
if err != nil {
fmt.Println("Datachannel closed; Exit the readloop:", err)
return
}
fmt.Printf("Message from DataChannel: %s\n", string(buffer[:n]))
}
}
// WriteLoop shows how to write to the datachannel directly.
func WriteLoop(d io.Writer) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, err := randutil.GenerateCryptoRandomString(
messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
)
if err != nil {
panic(err)
}
fmt.Printf("Sending %s \n", message)
if _, err := d.Write([]byte(message)); err != nil {
panic(err)
}
}
}
// httpSDPServer starts a HTTP Server that consumes SDPs.
func httpSDPServer(port int) chan string {
sdpChan := make(chan string)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
fmt.Fprintf(w, "done") //nolint: errcheck
sdpChan <- string(body)
})
go func() {
// nolint: gosec
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
}()
return sdpChan
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,12 @@
# data-channels-detach
data-channels-detach is an example that shows how you can detach a data channel. This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel). This allows you to interact with the data channel using a more idiomatic API based on the `io.ReadWriteCloser` interface.
The example mirrors the data-channels example.
## Install
```
go install github.com/pion/webrtc/v4/examples/data-channels-detach@latest
```
## Usage
The example can be used in the same way as the data-channel example or can be paired with the data-channels-detach-create example. In the latter case; run both example and exchange the offer/answer text by copy-pasting them on the other terminal.

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,20 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
Browser base64 Session Description<br />
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
Golang base64 Session Description<br />
<textarea id="remoteSessionDescription"></textarea><br/>
<button onclick="window.startSession()">Start Session</button><br />
<br />
<!--Message<br />
<textarea id="message">This is my DataChannel message!</textarea> <br/>
<button onclick="window.sendMessage()">Send Message</button> <br />-->
<br />
Logs<br />
<div id="logs"></div>

View File

@@ -0,0 +1,231 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"syscall/js"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
const messageSize = 15
func main() {
// Since this behavior diverges from the WebRTC API it has to be
// enabled using a settings engine. Mixing both detached and the
// OnMessage DataChannel API is not supported.
// Create a SettingEngine and enable Detach
s := webrtc.SettingEngine{}
s.DetachDataChannels()
// Create an API object with the engine
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection using the API object
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
handleError(err)
}
// Create a datachannel with label 'data'
dataChannel, err := peerConnection.CreateDataChannel("data", nil)
if err != nil {
handleError(err)
}
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
log(fmt.Sprintf("ICE Connection State has changed: %s\n", connectionState.String()))
})
// Register channel opening handling
dataChannel.OnOpen(func() {
log(fmt.Sprintf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID()))
// Detach the data channel
raw, dErr := dataChannel.Detach()
if dErr != nil {
handleError(dErr)
}
// Handle reading from the data channel
go ReadLoop(raw)
// Handle writing to the data channel
go WriteLoop(raw)
})
// Create an offer to send to the browser
offer, err := peerConnection.CreateOffer(nil)
if err != nil {
handleError(err)
}
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(offer)
if err != nil {
handleError(err)
}
// Add handlers for setting up the connection.
peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log(fmt.Sprint(state))
})
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
encodedDescr := encode(peerConnection.LocalDescription())
el := getElementByID("localSessionDescription")
el.Set("value", encodedDescr)
}
})
// Set up global callbacks which will be triggered on button clicks.
/*js.Global().Set("sendMessage", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
el := getElementByID("message")
message := el.Get("value").String()
if message == "" {
js.Global().Call("alert", "Message must not be empty")
return
}
if err := sendChannel.SendText(message); err != nil {
handleError(err)
}
}()
return js.Undefined()
}))*/
js.Global().Set("startSession", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
el := getElementByID("remoteSessionDescription")
sd := el.Get("value").String()
if sd == "" {
js.Global().Call("alert", "Session Description must not be empty")
return
}
descr := webrtc.SessionDescription{}
decode(sd, &descr)
if err := peerConnection.SetRemoteDescription(descr); err != nil {
handleError(err)
}
}()
return js.Undefined()
}))
// Block forever
select {}
}
// ReadLoop shows how to read from the datachannel directly
func ReadLoop(d io.Reader) {
for {
buffer := make([]byte, messageSize)
n, err := d.Read(buffer)
if err != nil {
log(fmt.Sprintf("Datachannel closed; Exit the readloop: %v", err))
return
}
log(fmt.Sprintf("Message from DataChannel: %s\n", string(buffer[:n])))
}
}
// WriteLoop shows how to write to the datachannel directly
func WriteLoop(d io.Writer) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, err := randutil.GenerateCryptoRandomString(messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
if err != nil {
handleError(err)
}
log(fmt.Sprintf("Sending %s \n", message))
if _, err := d.Write([]byte(message)); err != nil {
handleError(err)
}
}
}
func log(msg string) {
el := getElementByID("logs")
el.Set("innerHTML", el.Get("innerHTML").String()+msg+"<br>")
}
func handleError(err error) {
log("Unexpected error. Check console.")
panic(err)
}
func getElementByID(id string) js.Value {
return js.Global().Get("document").Call("getElementById", id)
}
// Read from stdin until we get a newline
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,216 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// data-channels-detach is an example that shows how you can detach a data channel.
// This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel).
// This allows you to interact with the data channel using a more idiomatic API based on
// the `io.ReadWriteCloser` interface.
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
const messageSize = 15
func main() {
// Since this behavior diverges from the WebRTC API it has to be
// enabled using a settings engine. Mixing both detached and the
// OnMessage DataChannel API is not supported.
// Create a SettingEngine and enable Detach
s := webrtc.SettingEngine{}
s.DetachDataChannels()
// Create an API object with the engine
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection using the API object
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Register data channel creation handling
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
// Register channel opening handling
dataChannel.OnOpen(func() {
fmt.Printf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID())
// Detach the data channel
raw, dErr := dataChannel.Detach()
if dErr != nil {
panic(dErr)
}
// Handle reading from the data channel
go ReadLoop(raw)
// Handle writing to the data channel
go WriteLoop(raw)
})
})
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if 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
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Block forever
select {}
}
// ReadLoop shows how to read from the datachannel directly.
func ReadLoop(d io.Reader) {
for {
buffer := make([]byte, messageSize)
n, err := d.Read(buffer)
if err != nil {
fmt.Println("Datachannel closed; Exit the readloop:", err)
return
}
fmt.Printf("Message from DataChannel: %s\n", string(buffer[:n]))
}
}
// WriteLoop shows how to write to the datachannel directly.
func WriteLoop(d io.Writer) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, err := randutil.GenerateCryptoRandomString(
messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
)
if err != nil {
panic(err)
}
fmt.Printf("Sending %s \n", message)
if _, err := d.Write([]byte(message)); err != nil {
panic(err)
}
}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,63 @@
# data-channels-flow-control
This example demonstrates how to use the following property / methods.
* func (d *DataChannel) BufferedAmount() uint64
* func (d *DataChannel) SetBufferedAmountLowThreshold(th uint64)
* func (d *DataChannel) BufferedAmountLowThreshold() uint64
* func (d *DataChannel) OnBufferedAmountLow(f func())
These methods are equivalent to that of JavaScript WebRTC API.
See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel for more details.
## When do we need it?
Send or SendText methods are called on DataChannel to send data to the connected peer.
The methods return immediately, but it does not mean the data was actually sent onto
the wire. Instead, it is queued in a buffer until it actually gets sent out to the wire.
When you have a large amount of data to send, it is an application's responsibility to
control the buffered amount in order not to indefinitely grow the buffer size to eventually
exhaust the memory.
The rate you wish to send data might be much higher than the rate the data channel can
actually send to the peer over the Internet. The above properties/methods help your
application to pace the amount of data to be pushed into the data channel.
## How to run the example code
The demo code (main.go) implements two endpoints (offerPC and answerPC) in it.
```
signaling messages
+----------------------------------------+
| |
v v
+---------------+ +---------------+
| | data | |
| offerPC |----------------------->| answerPC |
|:PeerConnection| |:PeerConnection|
+---------------+ +---------------+
```
First offerPC and answerPC will exchange signaling message to establish a peer-to-peer
connection, and data channel (label: "data").
Once the data channel is successfully opened, offerPC will start sending a series of
1024-byte packets to answerPC as fast as it can, until you kill the process by Ctrl-c.
Here's how to run the code.
At the root of the example, `pion/webrtc/examples/data-channels-flow-control/`:
```
$ go run main.go
2019/08/31 14:56:41 OnOpen: data-824635025728. Start sending a series of 1024-byte packets as fast as it can
2019/08/31 14:56:41 OnOpen: data-824637171120. Start receiving data
2019/08/31 14:56:42 Throughput: 179.118 Mbps
2019/08/31 14:56:43 Throughput: 203.545 Mbps
2019/08/31 14:56:44 Throughput: 211.516 Mbps
2019/08/31 14:56:45 Throughput: 216.292 Mbps
2019/08/31 14:56:46 Throughput: 217.961 Mbps
2019/08/31 14:56:47 Throughput: 218.342 Mbps
:
```

View File

@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// data-channels-flow-control demonstrates how to use the DataChannel congestion control APIs
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
)
const (
bufferedAmountLowThreshold uint64 = 512 * 1024 // 512 KB
maxBufferedAmount uint64 = 1024 * 1024 // 1 MB
)
func check(err error) {
if err != nil {
panic(err)
}
}
func setRemoteDescription(pc *webrtc.PeerConnection, sdp []byte) {
var desc webrtc.SessionDescription
err := json.Unmarshal(sdp, &desc)
check(err)
// Apply the desc as the remote description
err = pc.SetRemoteDescription(desc)
check(err)
}
func createOfferer() *webrtc.PeerConnection {
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{},
}
// Create a new PeerConnection
pc, err := webrtc.NewPeerConnection(config)
check(err)
buf := make([]byte, 1024)
ordered := false
maxRetransmits := uint16(0)
options := &webrtc.DataChannelInit{
Ordered: &ordered,
MaxRetransmits: &maxRetransmits,
}
sendMoreCh := make(chan struct{}, 1)
// Create a datachannel with label 'data'
dataChannel, err := pc.CreateDataChannel("data", options)
check(err)
// Register channel opening handling
dataChannel.OnOpen(func() {
log.Printf(
"OnOpen: %s-%d. Start sending a series of 1024-byte packets as fast as it can\n",
dataChannel.Label(), dataChannel.ID(),
)
for {
err2 := dataChannel.Send(buf)
check(err2)
if dataChannel.BufferedAmount() > maxBufferedAmount {
// Wait until the bufferedAmount becomes lower than the threshold
<-sendMoreCh
}
}
})
// Set bufferedAmountLowThreshold so that we can get notified when
// we can send more
dataChannel.SetBufferedAmountLowThreshold(bufferedAmountLowThreshold)
// This callback is made when the current bufferedAmount becomes lower than the threshold
dataChannel.OnBufferedAmountLow(func() {
select {
case sendMoreCh <- struct{}{}:
default:
}
})
return pc
}
func createAnswerer() *webrtc.PeerConnection {
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{},
}
// Create a new PeerConnection
pc, err := webrtc.NewPeerConnection(config)
check(err)
pc.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
var totalBytesReceived uint64
// Register channel opening handling
dataChannel.OnOpen(func() {
log.Printf("OnOpen: %s-%d. Start receiving data", dataChannel.Label(), dataChannel.ID())
since := time.Now()
// Start printing out the observed throughput
ticker := time.NewTicker(1000 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
bps := float64(atomic.LoadUint64(&totalBytesReceived)*8) / time.Since(since).Seconds()
log.Printf("Throughput: %.03f Mbps", bps/1024/1024)
}
})
// Register the OnMessage to handle incoming messages
dataChannel.OnMessage(func(dcMsg webrtc.DataChannelMessage) {
n := len(dcMsg.Data)
atomic.AddUint64(&totalBytesReceived, uint64(n))
})
})
return pc
}
func main() {
offerPC := createOfferer()
defer func() {
if err := offerPC.Close(); err != nil {
fmt.Printf("cannot close offerPC: %v\n", err)
}
}()
answerPC := createAnswerer()
defer func() {
if err := answerPC.Close(); err != nil {
fmt.Printf("cannot close answerPC: %v\n", err)
}
}()
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
// send it to the other peer
answerPC.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
check(offerPC.AddICECandidate(candidate.ToJSON()))
}
})
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
// send it to the other peer
offerPC.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
check(answerPC.AddICECandidate(candidate.ToJSON()))
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
offerPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s (offerer)\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
answerPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s (answerer)\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Now, create an offer
offer, err := offerPC.CreateOffer(nil)
check(err)
check(offerPC.SetLocalDescription(offer))
desc, err := json.Marshal(offer)
check(err)
setRemoteDescription(answerPC, desc)
answer, err := answerPC.CreateAnswer(nil)
check(err)
check(answerPC.SetLocalDescription(answer))
desc2, err := json.Marshal(answer)
check(err)
setRemoteDescription(offerPC, desc2)
// Block forever
select {}
}

View File

@@ -0,0 +1,23 @@
# WebRTC DataChannel Example in Go
This is a minimal example of a **WebRTC DataChannel** using **Go (Pion)** as the signaling server.
## Features
- Go server for signaling
- Browser-based DataChannel
- ICE candidate exchange
- Real-time messaging between browser and Go server
## Usage
1. Run the server:
```
go run main.go
```
2. Open browser at http://localhost:8080
3. Send messages via DataChannel and see them in terminal & browser logs.

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<meta charset="utf-8">
<title>DataChannel Test</title>
</head>
<body>
<h2>📡 WebRTC DataChannel Test</h2>
<input id="msg" placeholder="Message">
<button id="sendBtn" disabled onclick="sendMsg()">Send</button>
<pre id="log"></pre>
<script>
const pc = new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]});
const channel = pc.createDataChannel("chat");
// Connection state monitoring
pc.onconnectionstatechange = () => log(`🔄 Connection state: ${pc.connectionState}`);
pc.oniceconnectionstatechange = () => log(`🧊 ICE state: ${pc.iceConnectionState}`);
pc.onsignalingstatechange = () => log(`📞 Signaling state: ${pc.signalingState}`);
channel.onopen = () => {
log("✅ DataChannel opened");
document.getElementById("sendBtn").disabled = false;
}
channel.onmessage = e => log(`📩 Server: ${e.data}`);
pc.onicecandidate = event => {
if(event.candidate){
fetch("/candidate", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(event.candidate),
});
}
};
async function start(){
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch("/offer", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(offer),
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const answer = await res.json();
await pc.setRemoteDescription(answer);
} catch (err) {
log(`❌ Connection failed: ${err.message}`);
console.error("Connection error:", err);
}
}
function sendMsg(){
if(channel.readyState !== "open"){
log("❌ Channel not open yet");
return;
}
const msg = document.getElementById("msg").value;
if (msg.trim()) {
channel.send(msg);
log(`You: ${msg}`);
document.getElementById("msg").value = "";
}
}
function log(msg){
document.getElementById("log").textContent+=msg+"\n";
}
start();
</script>
</body>
</html>

View File

@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// simple-datachannel is a simple datachannel demo that auto connects.
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/pion/webrtc/v4"
)
func main() {
var pc *webrtc.PeerConnection
setupOfferHandler(&pc)
setupCandidateHandler(&pc)
setupStaticHandler()
fmt.Println("🚀 Signaling server started on http://localhost:8080")
//nolint:gosec
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Printf("Failed to start server: %v\n", err)
}
}
func setupOfferHandler(pc **webrtc.PeerConnection) {
http.HandleFunc("/offer", func(responseWriter http.ResponseWriter, r *http.Request) {
var offer webrtc.SessionDescription
if err := json.NewDecoder(r.Body).Decode(&offer); err != nil {
http.Error(responseWriter, err.Error(), http.StatusBadRequest)
return
}
// PeerConnection with enhanced configuration for better browser compatibility
var err error
*pc, err = webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
BundlePolicy: webrtc.BundlePolicyBalanced,
RTCPMuxPolicy: webrtc.RTCPMuxPolicyRequire,
})
if err != nil {
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
return
}
setupICECandidateHandler(*pc)
setupDataChannelHandler(*pc)
if err := processOffer(*pc, offer, responseWriter); err != nil {
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
return
}
})
}
func setupICECandidateHandler(pc *webrtc.PeerConnection) {
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c != nil {
fmt.Printf("🌐 New ICE candidate: %s\n", c.Address)
}
})
}
func setupDataChannelHandler(pc *webrtc.PeerConnection) {
pc.OnDataChannel(func(d *webrtc.DataChannel) {
d.OnOpen(func() {
fmt.Println("✅ DataChannel opened (Server)")
if sendErr := d.SendText("Hello from Go server 👋"); sendErr != nil {
fmt.Printf("Failed to send text: %v\n", sendErr)
}
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {
fmt.Printf("📩 Received: %s\n", string(msg.Data))
})
})
}
func processOffer(
pc *webrtc.PeerConnection,
offer webrtc.SessionDescription,
responseWriter http.ResponseWriter,
) error {
// Set remote description
if err := pc.SetRemoteDescription(offer); err != nil {
return err
}
// Create answer
answer, err := pc.CreateAnswer(nil)
if err != nil {
return err
}
// Set local description
if err := pc.SetLocalDescription(answer); err != nil {
return err
}
// Wait for ICE gathering to complete before sending answer
gatherComplete := webrtc.GatheringCompletePromise(pc)
<-gatherComplete
finalAnswer := pc.LocalDescription()
if finalAnswer == nil {
//nolint:err113
return fmt.Errorf("local description is nil after ICE gathering")
}
responseWriter.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(responseWriter).Encode(*finalAnswer); err != nil {
fmt.Printf("Failed to encode answer: %v\n", err)
}
return nil
}
func setupCandidateHandler(pc **webrtc.PeerConnection) {
http.HandleFunc("/candidate", func(responseWriter http.ResponseWriter, r *http.Request) {
var candidate webrtc.ICECandidateInit
if err := json.NewDecoder(r.Body).Decode(&candidate); err != nil {
http.Error(responseWriter, err.Error(), http.StatusBadRequest)
return
}
if *pc != nil {
if err := (*pc).AddICECandidate(candidate); err != nil {
fmt.Println("Failed to add candidate", err)
}
}
})
}
func setupStaticHandler() {
// demo.html
http.HandleFunc("/", func(responseWriter http.ResponseWriter, r *http.Request) {
http.ServeFile(responseWriter, r, "./demo.html")
})
}

View 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

View File

@@ -0,0 +1,97 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 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>

View File

@@ -0,0 +1,257 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go: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
}

View File

@@ -0,0 +1,80 @@
# data-channels
data-channels is Pion's sample WebRTC app that lets you send and receive DataChannel messages from a web browser.
## Brief Overview
This example will result in messages being sent between a browser and a self-hosted data-channels server. The connection is made by grabbing the browser's generated session description, or SDP, and passing it into the server. The server uses the browser's SDP to then return a new SDP from the server based on the browser's SDP. The server's SDP then gets passed back into the browser which confirms the handshake and forms a connection!
Once the connection is established, messages will automatically be sent from the data-channels server to the browser every 5 seconds. The browser has a button that lets you send a message back to the server when you click on it.
## Instructions
### 1. Download the data-channels server
```
go install github.com/pion/webrtc/v4/examples/data-channels@latest
```
### 2. Open JSFiddle
[Open this JSFiddle example page.](https://jsfiddle.net/e41tgovp/)
The top of the JSFiddle example page contains a text box containing your browser's session description (SDP).
Press `Copy browser SDP to clipboard` or copy the base64 string manually.
### 3. Send the browser's SDP to the server
Depending on your OS:
#### Linux/macOS (including WSL)
In the following command, replace `$BROWSER_SDP` with the copied string.
Run `echo $BROWSER_SDP | data-channels`.
#### Windows
1. Paste the copied string into a file.
2. Run `data-channels < my_file`.
### 4. Send the server's SDP back to the browser
The server will automatically print out a base64 string. Copy it and paste it into the second textbox in the JSFiddle page.
### 5. Start the session!
Under Start Session you should see 'Checking' as it starts connecting. If everything worked you should see `New DataChannel foo 1`.
Pion WebRTC will send random messages every 5 seconds that will appear in your browser.
### 6. Send a message from the browser to the server!
You can put whatever you want in the `Message` text area, and when you hit `Send Message` it should appear in your terminal!
## Example finished!
Congrats, you have used Pion WebRTC! Now start building something cool :)
## Architecture Overview
```mermaid
flowchart TB
Browser--Copy Offer from TextArea-->Pion
Pion--Copy Text Print to Console-->Browser
subgraph Pion[Go Peer]
p1[Create PeerConnection]
p2[OnConnectionState Handler]
p3[Print Connection State]
p2-->p3
p4[OnDataChannel Handler]
p5[OnDataChannel Open]
p6[Send Random Message every 5 seconds to DataChannel]
p4-->p5-->p6
p7[OnDataChannel Message]
p8[Log Incoming Message to Console]
p4-->p7-->p8
p9[Read Session Description from Standard Input]
p10[SetRemoteDescription with Session Description from Standard Input]
p11[Create Answer]
p12[Block until ICE Gathering is Complete]
p13[Print Answer with ICE Candidatens included to Standard Output]
end
subgraph Browser[Browser Peer]
b1[Create PeerConnection]
b2[Create DataChannel 'foo']
b3[OnDataChannel Message]
b4[Log Incoming Message to Console]
b3-->b4
b5[Create Offer]
b6[SetLocalDescription with Offer]
b7[Print Offer with ICE Candidates included]
end
```

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,8 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: data-channels
description: Example of using Pion WebRTC to communicate with a web browser using bi-direction DataChannels
authors:
- Sean DuBois

View File

@@ -0,0 +1,25 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
Browser base64 Session Description<br />
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
<button onclick="window.copySDP()">
Copy browser SDP to clipboard
</button>
<br />
<br />
Golang base64 Session Description<br />
<textarea id="remoteSessionDescription"></textarea><br/>
<button onclick="window.startSession()">Start Session</button><br />
<br />
Message<br />
<textarea id="message">This is my DataChannel message!</textarea> <br/>
<button onclick="window.sendMessage()">Send Message</button> <br />
<br />
Logs<br />
<div id="logs"></div>

View File

@@ -0,0 +1,67 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
const pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
const log = msg => {
document.getElementById('logs').innerHTML += msg + '<br>'
}
const sendChannel = pc.createDataChannel('foo')
sendChannel.onclose = () => console.log('sendChannel has closed')
sendChannel.onopen = () => console.log('sendChannel has opened')
sendChannel.onmessage = e => log(`Message from DataChannel '${sendChannel.label}' payload '${e.data}'`)
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
pc.onicecandidate = event => {
if (event.candidate === null) {
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
}
}
pc.onnegotiationneeded = e =>
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
window.sendMessage = () => {
const message = document.getElementById('message').value
if (message === '') {
return alert('Message must not be empty')
}
sendChannel.send(message)
}
window.startSession = () => {
const sd = document.getElementById('remoteSessionDescription').value
if (sd === '') {
return alert('Session Description must not be empty')
}
try {
pc.setRemoteDescription(JSON.parse(atob(sd)))
} catch (e) {
alert(e)
}
}
window.copySDP = () => {
const browserSDP = document.getElementById('localSessionDescription')
browserSDP.focus()
browserSDP.select()
try {
const successful = document.execCommand('copy')
const msg = successful ? 'successful' : 'unsuccessful'
log('Copying SDP was ' + msg)
} catch (err) {
log('Unable to copy SDP ' + err)
}
}

View File

@@ -0,0 +1,202 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"syscall/js"
"github.com/pion/webrtc/v4"
)
func main() {
// Configure and create a new PeerConnection.
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
pc, err := webrtc.NewPeerConnection(config)
if err != nil {
handleError(err)
}
// Create DataChannel.
sendChannel, err := pc.CreateDataChannel("foo", nil)
if err != nil {
handleError(err)
}
sendChannel.OnClose(func() {
fmt.Println("sendChannel has closed")
})
sendChannel.OnClosing(func() {
fmt.Println("sendChannel is closing")
})
sendChannel.OnError(func(err error) {
fmt.Println("sendChannel error", err)
})
sendChannel.OnOpen(func() {
fmt.Println("sendChannel has opened")
candidatePair, err := pc.SCTP().Transport().ICETransport().GetSelectedCandidatePair()
fmt.Println(candidatePair)
fmt.Println(err)
})
sendChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
log(fmt.Sprintf("Message from DataChannel %s payload %s", sendChannel.Label(), string(msg.Data)))
})
// Create offer
offer, err := pc.CreateOffer(nil)
if err != nil {
handleError(err)
}
if err := pc.SetLocalDescription(offer); err != nil {
handleError(err)
}
// Add handlers for setting up the connection.
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log(fmt.Sprint(state))
})
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
encodedDescr := encode(pc.LocalDescription())
el := getElementByID("localSessionDescription")
el.Set("value", encodedDescr)
}
})
// Set up global callbacks which will be triggered on button clicks.
js.Global().Set("sendMessage", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
el := getElementByID("message")
message := el.Get("value").String()
if message == "" {
js.Global().Call("alert", "Message must not be empty")
return
}
if err := sendChannel.SendText(message); err != nil {
handleError(err)
}
}()
return js.Undefined()
}))
js.Global().Set("startSession", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
el := getElementByID("remoteSessionDescription")
sd := el.Get("value").String()
if sd == "" {
js.Global().Call("alert", "Session Description must not be empty")
return
}
descr := webrtc.SessionDescription{}
decode(sd, &descr)
if err := pc.SetRemoteDescription(descr); err != nil {
handleError(err)
}
}()
return js.Undefined()
}))
js.Global().Set("copySDP", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
defer func() {
if e := recover(); e != nil {
switch e := e.(type) {
case error:
handleError(e)
default:
handleError(fmt.Errorf("recovered with non-error value: (%T) %s", e, e))
}
}
}()
browserSDP := getElementByID("localSessionDescription")
browserSDP.Call("focus")
browserSDP.Call("select")
copyStatus := js.Global().Get("document").Call("execCommand", "copy")
if copyStatus.Bool() {
log("Copying SDP was successful")
} else {
log("Copying SDP was unsuccessful")
}
}()
return js.Undefined()
}))
// Stay alive
select {}
}
func log(msg string) {
el := getElementByID("logs")
el.Set("innerHTML", el.Get("innerHTML").String()+msg+"<br>")
}
func handleError(err error) {
log("Unexpected error. Check console.")
panic(err)
}
func getElementByID(id string) js.Value {
return js.Global().Get("document").Call("getElementById", id)
}
// Read from stdin until we get a newline
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,178 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// data-channels is a Pion WebRTC application that shows how you can send/recv DataChannel messages from a web browser
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
// nolint:cyclop
func main() {
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Register data channel creation handling
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
// Register channel opening handling
dataChannel.OnOpen(func() {
fmt.Printf(
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
dataChannel.Label(), dataChannel.ID(),
)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, sendErr := randutil.GenerateCryptoRandomString(15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
if sendErr != nil {
panic(sendErr)
}
// Send the message as text
fmt.Printf("Sending '%s'\n", message)
if sendErr = dataChannel.SendText(message); sendErr != nil {
panic(sendErr)
}
}
})
// Register text message handling
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), string(msg.Data))
})
})
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
// Create an answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if 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
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Block forever
select {}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

38
examples/example.html Normal file
View File

@@ -0,0 +1,38 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link href="https://fonts.googleapis.com/css?family=Quicksand:400,500,700" rel="stylesheet">
<title>{{ .Title }} | Pion</title>
<style>
body {
font-family: "Quicksand", sans-serif;
font-weight: 400;
margin: 4em 10%;
}
</style>
<link rel="stylesheet" type="text/css" href="demo.css">
</head>
<body>
<h1>{{ .Title }}</h1>
<p><a href="/">< Home</a></p>
<div>
{{ template "demo.html" }}
</div>
</body>
{{ if .JS }}
<script src="demo.js"></script>
{{ else }}
<script src="/wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("demo.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
</script>
{{ end }}
</html>

152
examples/examples.go Normal file
View File

@@ -0,0 +1,152 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// HTTP server that demonstrates Pion WebRTC examples
package main
import (
"encoding/json"
"flag"
"go/build"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// Examples represents the examples loaded from examples.json.
type Examples []*Example
// Example represents an example loaded from examples.json.
type Example struct {
Title string `json:"title"`
Link string `json:"link"`
Description string `json:"description"`
Type string `json:"type"`
IsJS bool
IsWASM bool
}
func main() {
addr := flag.String("address", ":80", "Address to host the HTTP server on.")
flag.Parse()
log.Println("Listening on", *addr)
err := serve(*addr)
if err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
func serve(addr string) error {
// Load the examples
examples := getExamples()
// Load the templates
homeTemplate := template.Must(template.ParseFiles("index.html"))
// Serve the required pages
// DIY 'mux' to avoid additional dependencies
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
url := req.URL.Path
if url == "/wasm_exec.js" {
http.FileServer(http.Dir(filepath.Join(build.Default.GOROOT, "lib/wasm/"))).ServeHTTP(res, req)
return
}
// Split up the URL. Expected parts:
// 1: Base url
// 2: "example"
// 3: Example type: js or wasm
// 4: Example folder, e.g.: data-channels
// 5: Static file as part of the example
parts := strings.Split(url, "/")
if len(parts) > 4 &&
parts[1] == "example" {
exampleType := parts[2]
exampleLink := parts[3]
for _, example := range *examples {
if example.Link != exampleLink {
continue
}
fiddle := filepath.Join(exampleLink, "jsfiddle")
if len(parts[4]) != 0 {
http.StripPrefix(
"/example/"+exampleType+"/"+exampleLink+"/",
http.FileServer(http.Dir(fiddle)),
).ServeHTTP(res, req)
return
}
temp := template.Must(template.ParseFiles("example.html"))
_, err := temp.ParseFiles(filepath.Join(fiddle, "demo.html"))
if err != nil {
panic(err)
}
data := struct {
*Example
JS bool
}{
example,
exampleType == "js",
}
err = temp.Execute(res, data)
if err != nil {
panic(err)
}
return
}
}
// Serve the main page
err := homeTemplate.Execute(res, examples)
if err != nil {
panic(err)
}
})
// Start the server
// nolint: gosec
return http.ListenAndServe(addr, nil)
}
// getExamples loads the examples from the examples.json file.
func getExamples() *Examples {
file, err := os.Open("./examples.json")
if err != nil {
panic(err)
}
defer func() {
closeErr := file.Close()
if closeErr != nil {
panic(closeErr)
}
}()
var examples Examples
err = json.NewDecoder(file).Decode(&examples)
if err != nil {
panic(err)
}
for _, example := range examples {
fiddle := filepath.Join(example.Link, "jsfiddle")
js := filepath.Join(fiddle, "demo.js")
if _, err := os.Stat(js); !os.IsNotExist(err) {
example.IsJS = true
}
wasm := filepath.Join(fiddle, "demo.wasm")
if _, err := os.Stat(wasm); !os.IsNotExist(err) {
example.IsWASM = true
}
}
return &examples
}

Some files were not shown because too many files have changed in this diff Show More