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
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:
6
.codacy.yaml
Normal file
6
.codacy.yaml
Normal 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
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["standard"]
|
||||
}
|
||||
5
.github/.ci.conf
vendored
Normal file
5
.github/.ci.conf
vendored
Normal 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
4
.github/.gitignore
vendored
Normal 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
31
.github/fetch-scripts.sh
vendored
Executable 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
19
.github/install-hooks.sh
vendored
Executable 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
BIN
.github/pion-gopher-webrtc.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
2
.github/pion-gopher-webrtc.png.license
vendored
Normal file
2
.github/pion-gopher-webrtc.png.license
vendored
Normal 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
20
.github/workflows/api.yaml
vendored
Normal 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
22
.github/workflows/browser-e2e.yaml
vendored
Normal 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
28
.github/workflows/codeql-analysis.yml
vendored
Normal 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
20
.github/workflows/examples-tests.yaml
vendored
Normal 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
27
.github/workflows/fuzz.yaml
vendored
Normal 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
22
.github/workflows/lint.yaml
vendored
Normal 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
24
.github/workflows/release.yml
vendored
Normal 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
|
||||
24
.github/workflows/renovate-go-sum-fix.yaml
vendored
Normal file
24
.github/workflows/renovate-go-sum-fix.yaml
vendored
Normal 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
22
.github/workflows/reuse.yml
vendored
Normal 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
19
.github/workflows/standardjs.yaml
vendored
Normal 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
63
.github/workflows/test.yaml
vendored
Normal 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
25
.github/workflows/tidy-check.yaml
vendored
Normal 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
28
.gitignore
vendored
Normal 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
155
.golangci.yml
Normal 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
5
.goreleaser.yml
Normal 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
11
.reuse/dep5
Normal 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
43
DESIGN.md
Normal 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
9
LICENSE
Normal 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
9
LICENSES/MIT.txt
Normal 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
139
README.md
Normal 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
96
api.go
Normal 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
35
api_js.go
Normal 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
43
api_test.go
Normal 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
85
bundlepolicy.go
Normal 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
50
bundlepolicy_test.go
Normal 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
262
certificate.go
Normal 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
174
certificate_js_test.go
Normal 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
173
certificate_test.go
Normal 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
22
codecov.yml
Normal 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
58
configuration.go
Normal 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
28
configuration_common.go
Normal 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
45
configuration_js.go
Normal 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
79
configuration_test.go
Normal 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
70
constants.go
Normal 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
773
datachannel.go
Normal 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
979
datachannel_go_test.go
Normal 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
419
datachannel_js.go
Normal 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
75
datachannel_js_detach.go
Normal 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
546
datachannel_test.go
Normal 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
36
datachannelinit.go
Normal 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
13
datachannelmessage.go
Normal 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
15
datachannelparameters.go
Normal 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
79
datachannelstate.go
Normal 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
52
datachannelstate_test.go
Normal 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
17
dtlsfingerprint.go
Normal 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
10
dtlsparameters.go
Normal 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
97
dtlsrole.go
Normal 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
84
dtlsrole_test.go
Normal 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
735
dtlstransport.go
Normal 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
65
dtlstransport_js.go
Normal 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
638
dtlstransport_test.go
Normal 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
89
dtlstransportstate.go
Normal 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
|
||||
}
|
||||
54
dtlstransportstate_test.go
Normal file
54
dtlstransportstate_test.go
Normal 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
16
e2e/Dockerfile
Normal 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
358
e2e/e2e_test.go
Normal 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
45
e2e/test.html
Normal 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
296
errors.go
Normal 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
70
examples/README.md
Normal 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.
|
||||
48
examples/bandwidth-estimation-from-disk/README.md
Normal file
48
examples/bandwidth-estimation-from-disk/README.md
Normal 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
|
||||
313
examples/bandwidth-estimation-from-disk/main.go
Normal file
313
examples/bandwidth-estimation-from-disk/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
40
examples/broadcast/README.md
Normal file
40
examples/broadcast/README.md
Normal 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
|
||||
8
examples/broadcast/jsfiddle/demo.css
Normal file
8
examples/broadcast/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
8
examples/broadcast/jsfiddle/demo.details
Normal file
8
examples/broadcast/jsfiddle/demo.details
Normal 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
|
||||
30
examples/broadcast/jsfiddle/demo.html
Normal file
30
examples/broadcast/jsfiddle/demo.html
Normal 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>
|
||||
82
examples/broadcast/jsfiddle/demo.js
Normal file
82
examples/broadcast/jsfiddle/demo.js
Normal 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
242
examples/broadcast/main.go
Normal 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
|
||||
}
|
||||
56
examples/custom-logger/README.md
Normal file
56
examples/custom-logger/README.md
Normal 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
|
||||
184
examples/custom-logger/main.go
Normal file
184
examples/custom-logger/main.go
Normal 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 {}
|
||||
}
|
||||
30
examples/data-channels-detach-create/README.md
Normal file
30
examples/data-channels-detach-create/README.md
Normal 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
|
||||
```
|
||||
211
examples/data-channels-detach-create/main.go
Normal file
211
examples/data-channels-detach-create/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
12
examples/data-channels-detach/README.md
Normal file
12
examples/data-channels-detach/README.md
Normal 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.
|
||||
8
examples/data-channels-detach/jsfiddle/demo.css
Normal file
8
examples/data-channels-detach/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
20
examples/data-channels-detach/jsfiddle/demo.html
Normal file
20
examples/data-channels-detach/jsfiddle/demo.html
Normal 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>
|
||||
231
examples/data-channels-detach/jsfiddle/main.go
Normal file
231
examples/data-channels-detach/jsfiddle/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
216
examples/data-channels-detach/main.go
Normal file
216
examples/data-channels-detach/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
63
examples/data-channels-flow-control/README.md
Normal file
63
examples/data-channels-flow-control/README.md
Normal 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
|
||||
:
|
||||
```
|
||||
227
examples/data-channels-flow-control/main.go
Normal file
227
examples/data-channels-flow-control/main.go
Normal 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 {}
|
||||
}
|
||||
23
examples/data-channels-simple/README.md
Normal file
23
examples/data-channels-simple/README.md
Normal 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.
|
||||
|
||||
88
examples/data-channels-simple/demo.html
Normal file
88
examples/data-channels-simple/demo.html
Normal 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>
|
||||
148
examples/data-channels-simple/main.go
Normal file
148
examples/data-channels-simple/main.go
Normal 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")
|
||||
})
|
||||
}
|
||||
63
examples/data-channels-whip-whep-like/README.md
Normal file
63
examples/data-channels-whip-whep-like/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# whip-whep-like
|
||||
|
||||
This example demonstrates a WHIP/WHEP-like implementation using Pion WebRTC with DataChannel support for real-time chat.
|
||||
|
||||
**Note:** This is similar to but not exactly WHIP/WHEP, as the official WHIP/WHEP specifications focus on media streaming only and do not include DataChannel support. This example extends the WHIP/WHEP pattern to demonstrate peer-to-peer chat functionality with automatic username assignment and message broadcasting.
|
||||
|
||||
Key features:
|
||||
- **Real-time chat** with WebRTC DataChannels
|
||||
- **Automatic username generation** - Each user gets a unique random username (e.g., SneakyBear46)
|
||||
- **Message broadcasting** - All connected users receive messages from everyone else
|
||||
- **WHIP/WHEP-like signaling** - Simple HTTP-based signaling for easy integration
|
||||
|
||||
Further details about WHIP+WHEP and the WebRTC DataChannel implementation are below the instructions.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download the example
|
||||
|
||||
This example requires you to clone the repo since it is serving static HTML.
|
||||
|
||||
```
|
||||
git clone https://github.com/pion/webrtc.git
|
||||
cd webrtc/examples/data-channels-whip-whep-like
|
||||
```
|
||||
|
||||
### Run the server
|
||||
Execute `go run *.go`
|
||||
|
||||
### Connect and chat
|
||||
|
||||
1. Open [http://localhost:8080](http://localhost:8080) in your browser
|
||||
2. Click "Publish" or "Subscribe" to establish a DataChannel connection
|
||||
3. You'll be assigned a random username (e.g., "SneakyBear46")
|
||||
4. Type a message and click "Send Message" to broadcast to all connected users
|
||||
5. Open multiple tabs/windows to test multi-user chat
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
|
||||
## Why WHIP/WHEP for signaling?
|
||||
|
||||
This example uses a WHIP/WHEP-like signaling approach where an Offer is uploaded via HTTP and the server responds with an Answer. This simple API contract makes it easy to integrate WebRTC into web applications.
|
||||
|
||||
**Difference from standard WHIP/WHEP:** The official WHIP/WHEP specifications are designed for media streaming (audio/video) only. This example extends that pattern to include DataChannel support for real-time chat functionality.
|
||||
|
||||
## Implementation details
|
||||
|
||||
### Username generation
|
||||
Each connected user is automatically assigned a unique username combining:
|
||||
- An adjective (e.g., Sneaky, Brave, Quick)
|
||||
- An animal noun (e.g., Bear, Fox, Eagle)
|
||||
- A random number (0-999)
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
|
||||
## Why WHIP/WHEP?
|
||||
|
||||
WHIP/WHEP mandates that a Offer is uploaded via HTTP. The server responds with a Answer. With this strong API contract WebRTC support can be added to tools like OBS.
|
||||
|
||||
For more info on WHIP/WHEP specification, feel free to read some of these great resources:
|
||||
- https://webrtchacks.com/webrtc-cracks-the-whip-on-obs/
|
||||
- https://datatracker.ietf.org/doc/draft-ietf-wish-whip/
|
||||
- https://datatracker.ietf.org/doc/draft-ietf-wish-whep/
|
||||
- https://bloggeek.me/whip-whep-webrtc-live-streaming
|
||||
97
examples/data-channels-whip-whep-like/index.html
Normal file
97
examples/data-channels-whip-whep-like/index.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<html>
|
||||
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 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>
|
||||
257
examples/data-channels-whip-whep-like/main.go
Normal file
257
examples/data-channels-whip-whep-like/main.go
Normal 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
|
||||
}
|
||||
80
examples/data-channels/README.md
Normal file
80
examples/data-channels/README.md
Normal 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
|
||||
```
|
||||
8
examples/data-channels/jsfiddle/demo.css
Normal file
8
examples/data-channels/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
8
examples/data-channels/jsfiddle/demo.details
Normal file
8
examples/data-channels/jsfiddle/demo.details
Normal 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
|
||||
25
examples/data-channels/jsfiddle/demo.html
Normal file
25
examples/data-channels/jsfiddle/demo.html
Normal 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>
|
||||
67
examples/data-channels/jsfiddle/demo.js
Normal file
67
examples/data-channels/jsfiddle/demo.js
Normal 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)
|
||||
}
|
||||
}
|
||||
202
examples/data-channels/jsfiddle/main.go
Normal file
202
examples/data-channels/jsfiddle/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
178
examples/data-channels/main.go
Normal file
178
examples/data-channels/main.go
Normal 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
38
examples/example.html
Normal 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
152
examples/examples.go
Normal 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
Reference in New Issue
Block a user