fastapi-webrtc 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_webrtc-0.1.0/.gitignore +5 -0
- fastapi_webrtc-0.1.0/CHANGELOG.md +51 -0
- fastapi_webrtc-0.1.0/LICENSE +21 -0
- fastapi_webrtc-0.1.0/PKG-INFO +245 -0
- fastapi_webrtc-0.1.0/README.md +215 -0
- fastapi_webrtc-0.1.0/docs/canon-dishka.md +92 -0
- fastapi_webrtc-0.1.0/docs/protocol.md +93 -0
- fastapi_webrtc-0.1.0/examples/__init__.py +0 -0
- fastapi_webrtc-0.1.0/examples/practice/__init__.py +1 -0
- fastapi_webrtc-0.1.0/examples/practice/app.py +167 -0
- fastapi_webrtc-0.1.0/examples/practice/client/.fastapi-webrtc.json +16 -0
- fastapi_webrtc-0.1.0/examples/practice/client/client.ts +113 -0
- fastapi_webrtc-0.1.0/examples/practice/client/index.ts +5 -0
- fastapi_webrtc-0.1.0/examples/practice/client/models.ts +21 -0
- fastapi_webrtc-0.1.0/examples/practice/client/runtime.ts +1160 -0
- fastapi_webrtc-0.1.0/examples/practice/contract.json +172 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/__init__.py +70 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/__init__.py +1 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/cli.py +117 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/templates/runtime.ts +1160 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/typescript.py +621 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/writer.py +73 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/contract.py +257 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/declarations.py +443 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/engine.py +244 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/engines/__init__.py +2 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/engines/aiortc.py +469 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/errors.py +120 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/events.py +52 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/ice.py +122 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/peer.py +230 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/protocol.py +160 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/py.typed +0 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/router.py +351 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/runtime.py +1280 -0
- fastapi_webrtc-0.1.0/fastapi_webrtc/sdp.py +92 -0
- fastapi_webrtc-0.1.0/package-lock.json +91 -0
- fastapi_webrtc-0.1.0/package.json +16 -0
- fastapi_webrtc-0.1.0/pyproject.toml +105 -0
- fastapi_webrtc-0.1.0/tests/__init__.py +0 -0
- fastapi_webrtc-0.1.0/tests/browser/index.html +9 -0
- fastapi_webrtc-0.1.0/tests/browser/page.ts +89 -0
- fastapi_webrtc-0.1.0/tests/browser/playwright.config.ts +41 -0
- fastapi_webrtc-0.1.0/tests/browser/practice.spec.ts +37 -0
- fastapi_webrtc-0.1.0/tests/browser/server.py +34 -0
- fastapi_webrtc-0.1.0/tests/browser/tsconfig.json +17 -0
- fastapi_webrtc-0.1.0/tests/contract_app.py +92 -0
- fastapi_webrtc-0.1.0/tests/engine/__init__.py +0 -0
- fastapi_webrtc-0.1.0/tests/engine/test_aiortc_sessions.py +244 -0
- fastapi_webrtc-0.1.0/tests/engine/test_aiortc_stats.py +43 -0
- fastapi_webrtc-0.1.0/tests/fake_engine.py +286 -0
- fastapi_webrtc-0.1.0/tests/peer_client.py +201 -0
- fastapi_webrtc-0.1.0/tests/server.py +32 -0
- fastapi_webrtc-0.1.0/tests/signaling.py +104 -0
- fastapi_webrtc-0.1.0/tests/test_codegen.py +657 -0
- fastapi_webrtc-0.1.0/tests/test_configuration.py +361 -0
- fastapi_webrtc-0.1.0/tests/test_contract.py +128 -0
- fastapi_webrtc-0.1.0/tests/test_runtime.py +1772 -0
- fastapi_webrtc-0.1.0/tests/typescript/fakes.ts +363 -0
- fastapi_webrtc-0.1.0/tests/typescript/runtime.test.ts +827 -0
- fastapi_webrtc-0.1.0/tests/typescript/tsconfig.json +18 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-10-20
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `WebRtcRouter.session()` declares WebRTC sessions as WebSocket signaling
|
|
8
|
+
endpoints. FastAPI dependencies admit sessions before any peer is created and
|
|
9
|
+
stay open until cleanup finished, also when the server cancels the endpoint.
|
|
10
|
+
- `Session` handlers: `on_start`, `run`, `on_track`, typed `on_message`,
|
|
11
|
+
`on_close` and outgoing tracks. `Peer` sends messages and tracks with
|
|
12
|
+
renegotiation, and exposes stats and close codes. Stats report jitter as
|
|
13
|
+
`None` when the engine cannot convert it to seconds (aiortc). `on_close` runs
|
|
14
|
+
once per accepted session and keeps at least one second even when cancelled
|
|
15
|
+
handlers used up `shutdown_timeout_seconds`.
|
|
16
|
+
- Track ownership: session tracks and `send_track(track)` are stopped on removal
|
|
17
|
+
or cleanup, including rejected and partially started sessions;
|
|
18
|
+
`send_track(track, owned=False)` keeps shared sources running behind a
|
|
19
|
+
sender-local forwarding track. Only active
|
|
20
|
+
incoming tracks count against `MediaPolicy.max_incoming_tracks`.
|
|
21
|
+
- Typed data channels from Pydantic models, `str` or `bytes`. JSON messages use
|
|
22
|
+
the contract's serialization aliases. Channels support negotiated IDs,
|
|
23
|
+
validated keyword-only reliability options, size limits, bounded inbound
|
|
24
|
+
queues and send backpressure. A `DataChannelSpec` is generic over its message
|
|
25
|
+
types: `on_message` accepts specs as keys, and `peer.channel(spec)` and
|
|
26
|
+
`peer.send_message(spec, ...)` are type-checked against its models.
|
|
27
|
+
`Never` as `receive_model` or `send_model` declares a one-way channel
|
|
28
|
+
(format `none`); the generated client omits `send` or `subscribe` for it.
|
|
29
|
+
- Signaling protocol `fastapi-webrtc.v1`. It uses perfect negotiation,
|
|
30
|
+
candidate buffering, ICE username checks, categorized close codes and
|
|
31
|
+
negotiation, media and transport deadlines. Negotiation timeouts and shutdown
|
|
32
|
+
cancel blocked ICE and engine startup.
|
|
33
|
+
- ICE server providers, `TurnRestCredentials` with separate engine URLs and
|
|
34
|
+
session event hooks.
|
|
35
|
+
- `WebRtcRouter(allowed_origins=...)` refuses cross-site browser handshakes with
|
|
36
|
+
HTTP 403 before the session factory runs.
|
|
37
|
+
- A pluggable `Engine` protocol, with an `aiortc` reference engine behind the
|
|
38
|
+
`aiortc` extra.
|
|
39
|
+
- Tested Canon/Dishka composition with explicit async-factory injection,
|
|
40
|
+
session services, per-message request scopes and ordered cleanup.
|
|
41
|
+
- `fastapi-webrtc contract` and `fastapi-webrtc generate` export a session
|
|
42
|
+
contract and generate a dependency-free TypeScript browser client with typed
|
|
43
|
+
parameters, channels and errors. Query models become flat named parameters.
|
|
44
|
+
Ambiguous generated names fail before files are written. Both commands support
|
|
45
|
+
`--check` drift detection.
|
|
46
|
+
- The browser client merges query values from the base URL, shared credential
|
|
47
|
+
providers, per-call maps and named parameters, in that order; `null` removes
|
|
48
|
+
a value. `timeoutMs` bounds credential resolution and connection setup;
|
|
49
|
+
`signal` cancels the whole session. Renegotiation
|
|
50
|
+
and channel sends (`sendTimeoutMs`) have deadlines after connect, and
|
|
51
|
+
permanent transport failure rejects pending operations.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mathis Arends
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fastapi-webrtc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed WebRTC sessions for FastAPI with signaling, engines and generated browser clients
|
|
5
|
+
Project-URL: Repository, https://github.com/mathisarends/canon
|
|
6
|
+
Project-URL: Source, https://github.com/mathisarends/canon/tree/main/packages/fastapi-webrtc
|
|
7
|
+
Project-URL: Documentation, https://github.com/mathisarends/canon/tree/main/packages/fastapi-webrtc/docs
|
|
8
|
+
Project-URL: Issues, https://github.com/mathisarends/canon/issues
|
|
9
|
+
Author-email: Mathis Arends <mathisarends27@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Communications :: Conferencing
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: <3.15,>=3.12
|
|
22
|
+
Requires-Dist: anyio>=4
|
|
23
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
24
|
+
Requires-Dist: pydantic<3,>=2.9
|
|
25
|
+
Requires-Dist: starlette>=0.41
|
|
26
|
+
Requires-Dist: typing-extensions>=4.12
|
|
27
|
+
Provides-Extra: aiortc
|
|
28
|
+
Requires-Dist: aiortc<2,>=1.14; extra == 'aiortc'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# fastapi-webrtc
|
|
32
|
+
|
|
33
|
+
Typed WebRTC sessions for FastAPI. You declare a session like a route: its
|
|
34
|
+
factory is an ordinary FastAPI dependency, so authentication, path and query
|
|
35
|
+
parameters and `yield` cleanup work as usual. They all run before any peer
|
|
36
|
+
connection exists. The package exports a contract and generates a typed,
|
|
37
|
+
framework-free browser client from it.
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
pip install "fastapi-webrtc[aiortc]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The core depends on FastAPI, Pydantic and Starlette >=0.41. The Starlette
|
|
44
|
+
lower bound ensures that an admission dependency's `HTTPException` returns an
|
|
45
|
+
HTTP response before accepting the WebSocket. Media engines are adapters; the
|
|
46
|
+
`aiortc` extra installs the reference engine. Canon, RPCKit and GStreamer are
|
|
47
|
+
not dependencies.
|
|
48
|
+
|
|
49
|
+
## Server
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from typing import Annotated
|
|
53
|
+
|
|
54
|
+
from aiortc.contrib.media import MediaRelay
|
|
55
|
+
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
56
|
+
from pydantic import BaseModel
|
|
57
|
+
|
|
58
|
+
from fastapi_webrtc import (
|
|
59
|
+
DataChannelSpec,
|
|
60
|
+
Direction,
|
|
61
|
+
IncomingTrack,
|
|
62
|
+
MediaPolicy,
|
|
63
|
+
Peer,
|
|
64
|
+
Session,
|
|
65
|
+
WebRtcRouter,
|
|
66
|
+
)
|
|
67
|
+
from fastapi_webrtc.engines.aiortc import AiortcEngine
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Prompt(BaseModel):
|
|
71
|
+
text: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Feedback(BaseModel):
|
|
75
|
+
text: str
|
|
76
|
+
words: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# The spec types the channel handles: `peer.channel(MESSAGES)` receives
|
|
80
|
+
# `Prompt` and sends `Feedback`.
|
|
81
|
+
MESSAGES = DataChannelSpec("messages", receive_model=Prompt, send_model=Feedback)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def lesson_access(lesson_id: str, token: Annotated[str, Query()]) -> str:
|
|
85
|
+
if token != "demo":
|
|
86
|
+
raise HTTPException(status_code=403) # HTTP 403; no peer is created.
|
|
87
|
+
return lesson_id
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
rtc = WebRtcRouter(prefix="/rtc", engine=AiortcEngine(), max_sessions=50)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@rtc.session(
|
|
94
|
+
"/lessons/{lesson_id}",
|
|
95
|
+
name="practice",
|
|
96
|
+
media=MediaPolicy(audio=Direction.SEND_RECEIVE),
|
|
97
|
+
data_channels=(MESSAGES,),
|
|
98
|
+
)
|
|
99
|
+
def practice(lesson: Annotated[str, Depends(lesson_access)]) -> Session:
|
|
100
|
+
relay = MediaRelay()
|
|
101
|
+
|
|
102
|
+
async def on_track(peer: Peer, track: IncomingTrack) -> None:
|
|
103
|
+
await peer.send_track(relay.subscribe(track.native)) # Echo the microphone.
|
|
104
|
+
|
|
105
|
+
async def on_prompt(peer: Peer, prompt: Prompt) -> None:
|
|
106
|
+
await peer.send_message(
|
|
107
|
+
MESSAGES, Feedback(text=prompt.text, words=len(prompt.text.split()))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return Session(on_track=on_track, on_message={MESSAGES: on_prompt})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
app = FastAPI()
|
|
114
|
+
app.include_router(rtc)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A `Session` is assembled from handlers:
|
|
118
|
+
|
|
119
|
+
- `on_start(peer)`: runs once the transport is connected and channels are open.
|
|
120
|
+
- `run(peer)`: the session's main task. When it returns, the session closes normally.
|
|
121
|
+
- `on_track(peer, track)`: called for each incoming track. `track.recv()` or
|
|
122
|
+
`async for frame in track` reads frames.
|
|
123
|
+
- `on_message={spec: handler}`: validated messages per channel, keyed by its
|
|
124
|
+
`DataChannelSpec` or its name. Without a handler, a channel can be consumed
|
|
125
|
+
with `async for message in peer.channel(spec)`.
|
|
126
|
+
- `on_close(peer, code)`: runs before the engine releases resources.
|
|
127
|
+
- `tracks=[...]`: outgoing tracks. `ice_servers` overrides the router's ICE servers.
|
|
128
|
+
|
|
129
|
+
`Peer` sends with `send_message`, `send_track` and `remove_track`, which
|
|
130
|
+
renegotiate. `peer.channel(spec)` and `peer.send_message(spec, message)` are
|
|
131
|
+
typed by the spec's `receive_model` and `send_model`; a channel name gives an
|
|
132
|
+
untyped handle. Reliability options such as `ordered` are keyword-only.
|
|
133
|
+
|
|
134
|
+
`receive_model=Never` or `send_model=Never` declares a one-way channel. The
|
|
135
|
+
server drops and reports messages it does not accept, `send_message` raises
|
|
136
|
+
`WebRtcError(invalid_message)`, and `on_message` handlers or `async for` on a
|
|
137
|
+
channel without inbound messages are refused. The generated client omits
|
|
138
|
+
`send` or `subscribe` for that direction. `Never` is not inferred as a type
|
|
139
|
+
argument; annotate the spec, for example `DataChannelSpec[Never, Tick]`. It also provides `stats()`, `close(code)` and `wait_closed()`.
|
|
140
|
+
The session stops tracks it owns when it closes. It never stops tracks passed
|
|
141
|
+
with `owned=False`.
|
|
142
|
+
|
|
143
|
+
Set `role=Role.OFFERER` when the server should create the offer, for example
|
|
144
|
+
when the browser only watches.
|
|
145
|
+
|
|
146
|
+
## Browser client
|
|
147
|
+
|
|
148
|
+
```console
|
|
149
|
+
fastapi-webrtc contract --app app:app --output contract.json
|
|
150
|
+
fastapi-webrtc generate --contract contract.json --output src/rtc --client-name PracticeRtcClient
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { PracticeRtcClient, isRtcError } from "./rtc/index.js";
|
|
155
|
+
|
|
156
|
+
const client = new PracticeRtcClient({ baseUrl: "https://api.example.com" });
|
|
157
|
+
const microphone = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
158
|
+
|
|
159
|
+
const session = await client.practice.connect({
|
|
160
|
+
lessonId: "greetings", // path parameter
|
|
161
|
+
token: "demo", // query parameter
|
|
162
|
+
tracks: microphone, // borrowed: never stopped by the session
|
|
163
|
+
timeoutMs: 10_000,
|
|
164
|
+
onTrack: ({ track }) => {
|
|
165
|
+
audio.srcObject = new MediaStream([track]);
|
|
166
|
+
},
|
|
167
|
+
onMessage: { messages: (feedback) => console.log(feedback.words) },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await session.channels.messages.send({ text: "hello" }); // typed as Prompt
|
|
171
|
+
await session.close(); // idempotent
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`timeoutMs` limits connection setup, including asynchronous credential providers,
|
|
175
|
+
and stops applying once the session is ready. Use `signal` for cancellation of
|
|
176
|
+
the session's entire lifetime: aborting it also closes an already running audio
|
|
177
|
+
or video session. An `AbortSignal.timeout(...)` therefore ends the session when
|
|
178
|
+
its timer expires, even if connection setup already succeeded.
|
|
179
|
+
|
|
180
|
+
Later renegotiations, including `addTrack()` and `removeTrack()`, use the
|
|
181
|
+
server's `negotiation_timeout_ms` and end the session with `negotiation_timeout`
|
|
182
|
+
if they cannot complete. Channel sends wait at most `sendTimeoutMs` (10 seconds
|
|
183
|
+
by default) for opening or buffer drainage before ending with `slow_consumer`.
|
|
184
|
+
Permanent peer-connection failure rejects pending operations with `transport_lost`.
|
|
185
|
+
|
|
186
|
+
Failures are `RtcError`s with a stable `code`, such as `session_limit`,
|
|
187
|
+
`negotiation_timeout`, `media_rejected` or `transport_lost`. Browsers do not
|
|
188
|
+
expose the HTTP status of a refused WebSocket handshake. A session denied by a
|
|
189
|
+
dependency therefore fails with `connection_failed`. The full list of codes is
|
|
190
|
+
in [the protocol](docs/protocol.md#close-codes).
|
|
191
|
+
|
|
192
|
+
Both commands accept `--check`, which fails on drift without writing.
|
|
193
|
+
Generated directories contain a manifest. Stale generated files are removed;
|
|
194
|
+
files you add yourself are kept.
|
|
195
|
+
|
|
196
|
+
Configure shared credentials with `new PracticeRtcClient({ query: async () =>
|
|
197
|
+
({ token: await refreshToken() }) })`. The provider runs for every connection.
|
|
198
|
+
Query values are merged in this order, with later sources taking precedence:
|
|
199
|
+
the `baseUrl` query string, `client.config.query`, `connect({ query: ... })`, then
|
|
200
|
+
named connect parameters such as `connect({ lessonId: "greetings", token: "new" })`.
|
|
201
|
+
Use wire aliases as keys inside query maps. An omitted named parameter preserves
|
|
202
|
+
shared values; `null` explicitly removes a query value. Required query parameters
|
|
203
|
+
are checked after merging. Generated query fields are optional so shared
|
|
204
|
+
credentials can supply them; required path parameters must still be passed.
|
|
205
|
+
|
|
206
|
+
## Security
|
|
207
|
+
|
|
208
|
+
Session admission is ordinary FastAPI dependency code, so authorization stays in
|
|
209
|
+
your application. Two WebSocket properties need attention:
|
|
210
|
+
|
|
211
|
+
- **Origins.** CORS does not apply to WebSocket handshakes, and browsers attach
|
|
212
|
+
cookies to cross-site handshakes. If admission relies on cookies, pass
|
|
213
|
+
`WebRtcRouter(allowed_origins=["https://app.example.com"])`. Handshakes from
|
|
214
|
+
other origins are refused with HTTP 403 before the session factory runs.
|
|
215
|
+
Requests without an `Origin` header come from non-browser clients and are not
|
|
216
|
+
restricted by this check.
|
|
217
|
+
- **Query tokens.** Browsers cannot set headers on a WebSocket, so tokens travel
|
|
218
|
+
in the URL. URLs can appear in proxy and server access logs. Prefer short-lived,
|
|
219
|
+
single-use session tickets issued by an authenticated HTTP endpoint over
|
|
220
|
+
long-lived access tokens, and supply them through the client's `query` provider.
|
|
221
|
+
|
|
222
|
+
## Limits and ICE
|
|
223
|
+
|
|
224
|
+
`SessionLimits` bounds negotiation time, pending candidates, signaling and
|
|
225
|
+
message sizes, inbound queues and send buffers. It also sets grace periods for
|
|
226
|
+
transport and signaling loss. Every limit has a categorized error.
|
|
227
|
+
`TurnRestCredentials` issues time-limited TURN REST credentials per user. It can
|
|
228
|
+
give the server-side engine internal relay URLs.
|
|
229
|
+
|
|
230
|
+
## Documentation
|
|
231
|
+
|
|
232
|
+
- [Signaling protocol](docs/protocol.md)
|
|
233
|
+
- [Canon composition and Dishka session/message scopes](docs/canon-dishka.md)
|
|
234
|
+
- [Changelog](CHANGELOG.md)
|
|
235
|
+
- Runnable example: [`examples/practice`](examples/practice). Its generated
|
|
236
|
+
client is in `examples/practice/client`.
|
|
237
|
+
|
|
238
|
+
The engine interface abstracts signaling and lifecycle. Native outgoing tracks
|
|
239
|
+
and received frames remain engine-specific: aiortc uses `MediaStreamTrack` and
|
|
240
|
+
PyAV frames. Its statistics return `jitter_seconds=None` because the public
|
|
241
|
+
report does not identify the per-stream codec clock rate needed for conversion.
|
|
242
|
+
|
|
243
|
+
Generated TypeScript models provide compile-time message types; the browser
|
|
244
|
+
runtime checks formats and sizes but does not enforce every JSON Schema rule.
|
|
245
|
+
Server-side Pydantic adapters validate incoming and outgoing typed messages.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# fastapi-webrtc
|
|
2
|
+
|
|
3
|
+
Typed WebRTC sessions for FastAPI. You declare a session like a route: its
|
|
4
|
+
factory is an ordinary FastAPI dependency, so authentication, path and query
|
|
5
|
+
parameters and `yield` cleanup work as usual. They all run before any peer
|
|
6
|
+
connection exists. The package exports a contract and generates a typed,
|
|
7
|
+
framework-free browser client from it.
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
pip install "fastapi-webrtc[aiortc]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The core depends on FastAPI, Pydantic and Starlette >=0.41. The Starlette
|
|
14
|
+
lower bound ensures that an admission dependency's `HTTPException` returns an
|
|
15
|
+
HTTP response before accepting the WebSocket. Media engines are adapters; the
|
|
16
|
+
`aiortc` extra installs the reference engine. Canon, RPCKit and GStreamer are
|
|
17
|
+
not dependencies.
|
|
18
|
+
|
|
19
|
+
## Server
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from typing import Annotated
|
|
23
|
+
|
|
24
|
+
from aiortc.contrib.media import MediaRelay
|
|
25
|
+
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
from fastapi_webrtc import (
|
|
29
|
+
DataChannelSpec,
|
|
30
|
+
Direction,
|
|
31
|
+
IncomingTrack,
|
|
32
|
+
MediaPolicy,
|
|
33
|
+
Peer,
|
|
34
|
+
Session,
|
|
35
|
+
WebRtcRouter,
|
|
36
|
+
)
|
|
37
|
+
from fastapi_webrtc.engines.aiortc import AiortcEngine
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Prompt(BaseModel):
|
|
41
|
+
text: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Feedback(BaseModel):
|
|
45
|
+
text: str
|
|
46
|
+
words: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# The spec types the channel handles: `peer.channel(MESSAGES)` receives
|
|
50
|
+
# `Prompt` and sends `Feedback`.
|
|
51
|
+
MESSAGES = DataChannelSpec("messages", receive_model=Prompt, send_model=Feedback)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def lesson_access(lesson_id: str, token: Annotated[str, Query()]) -> str:
|
|
55
|
+
if token != "demo":
|
|
56
|
+
raise HTTPException(status_code=403) # HTTP 403; no peer is created.
|
|
57
|
+
return lesson_id
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
rtc = WebRtcRouter(prefix="/rtc", engine=AiortcEngine(), max_sessions=50)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@rtc.session(
|
|
64
|
+
"/lessons/{lesson_id}",
|
|
65
|
+
name="practice",
|
|
66
|
+
media=MediaPolicy(audio=Direction.SEND_RECEIVE),
|
|
67
|
+
data_channels=(MESSAGES,),
|
|
68
|
+
)
|
|
69
|
+
def practice(lesson: Annotated[str, Depends(lesson_access)]) -> Session:
|
|
70
|
+
relay = MediaRelay()
|
|
71
|
+
|
|
72
|
+
async def on_track(peer: Peer, track: IncomingTrack) -> None:
|
|
73
|
+
await peer.send_track(relay.subscribe(track.native)) # Echo the microphone.
|
|
74
|
+
|
|
75
|
+
async def on_prompt(peer: Peer, prompt: Prompt) -> None:
|
|
76
|
+
await peer.send_message(
|
|
77
|
+
MESSAGES, Feedback(text=prompt.text, words=len(prompt.text.split()))
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return Session(on_track=on_track, on_message={MESSAGES: on_prompt})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
app = FastAPI()
|
|
84
|
+
app.include_router(rtc)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A `Session` is assembled from handlers:
|
|
88
|
+
|
|
89
|
+
- `on_start(peer)`: runs once the transport is connected and channels are open.
|
|
90
|
+
- `run(peer)`: the session's main task. When it returns, the session closes normally.
|
|
91
|
+
- `on_track(peer, track)`: called for each incoming track. `track.recv()` or
|
|
92
|
+
`async for frame in track` reads frames.
|
|
93
|
+
- `on_message={spec: handler}`: validated messages per channel, keyed by its
|
|
94
|
+
`DataChannelSpec` or its name. Without a handler, a channel can be consumed
|
|
95
|
+
with `async for message in peer.channel(spec)`.
|
|
96
|
+
- `on_close(peer, code)`: runs before the engine releases resources.
|
|
97
|
+
- `tracks=[...]`: outgoing tracks. `ice_servers` overrides the router's ICE servers.
|
|
98
|
+
|
|
99
|
+
`Peer` sends with `send_message`, `send_track` and `remove_track`, which
|
|
100
|
+
renegotiate. `peer.channel(spec)` and `peer.send_message(spec, message)` are
|
|
101
|
+
typed by the spec's `receive_model` and `send_model`; a channel name gives an
|
|
102
|
+
untyped handle. Reliability options such as `ordered` are keyword-only.
|
|
103
|
+
|
|
104
|
+
`receive_model=Never` or `send_model=Never` declares a one-way channel. The
|
|
105
|
+
server drops and reports messages it does not accept, `send_message` raises
|
|
106
|
+
`WebRtcError(invalid_message)`, and `on_message` handlers or `async for` on a
|
|
107
|
+
channel without inbound messages are refused. The generated client omits
|
|
108
|
+
`send` or `subscribe` for that direction. `Never` is not inferred as a type
|
|
109
|
+
argument; annotate the spec, for example `DataChannelSpec[Never, Tick]`. It also provides `stats()`, `close(code)` and `wait_closed()`.
|
|
110
|
+
The session stops tracks it owns when it closes. It never stops tracks passed
|
|
111
|
+
with `owned=False`.
|
|
112
|
+
|
|
113
|
+
Set `role=Role.OFFERER` when the server should create the offer, for example
|
|
114
|
+
when the browser only watches.
|
|
115
|
+
|
|
116
|
+
## Browser client
|
|
117
|
+
|
|
118
|
+
```console
|
|
119
|
+
fastapi-webrtc contract --app app:app --output contract.json
|
|
120
|
+
fastapi-webrtc generate --contract contract.json --output src/rtc --client-name PracticeRtcClient
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { PracticeRtcClient, isRtcError } from "./rtc/index.js";
|
|
125
|
+
|
|
126
|
+
const client = new PracticeRtcClient({ baseUrl: "https://api.example.com" });
|
|
127
|
+
const microphone = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
128
|
+
|
|
129
|
+
const session = await client.practice.connect({
|
|
130
|
+
lessonId: "greetings", // path parameter
|
|
131
|
+
token: "demo", // query parameter
|
|
132
|
+
tracks: microphone, // borrowed: never stopped by the session
|
|
133
|
+
timeoutMs: 10_000,
|
|
134
|
+
onTrack: ({ track }) => {
|
|
135
|
+
audio.srcObject = new MediaStream([track]);
|
|
136
|
+
},
|
|
137
|
+
onMessage: { messages: (feedback) => console.log(feedback.words) },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await session.channels.messages.send({ text: "hello" }); // typed as Prompt
|
|
141
|
+
await session.close(); // idempotent
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`timeoutMs` limits connection setup, including asynchronous credential providers,
|
|
145
|
+
and stops applying once the session is ready. Use `signal` for cancellation of
|
|
146
|
+
the session's entire lifetime: aborting it also closes an already running audio
|
|
147
|
+
or video session. An `AbortSignal.timeout(...)` therefore ends the session when
|
|
148
|
+
its timer expires, even if connection setup already succeeded.
|
|
149
|
+
|
|
150
|
+
Later renegotiations, including `addTrack()` and `removeTrack()`, use the
|
|
151
|
+
server's `negotiation_timeout_ms` and end the session with `negotiation_timeout`
|
|
152
|
+
if they cannot complete. Channel sends wait at most `sendTimeoutMs` (10 seconds
|
|
153
|
+
by default) for opening or buffer drainage before ending with `slow_consumer`.
|
|
154
|
+
Permanent peer-connection failure rejects pending operations with `transport_lost`.
|
|
155
|
+
|
|
156
|
+
Failures are `RtcError`s with a stable `code`, such as `session_limit`,
|
|
157
|
+
`negotiation_timeout`, `media_rejected` or `transport_lost`. Browsers do not
|
|
158
|
+
expose the HTTP status of a refused WebSocket handshake. A session denied by a
|
|
159
|
+
dependency therefore fails with `connection_failed`. The full list of codes is
|
|
160
|
+
in [the protocol](docs/protocol.md#close-codes).
|
|
161
|
+
|
|
162
|
+
Both commands accept `--check`, which fails on drift without writing.
|
|
163
|
+
Generated directories contain a manifest. Stale generated files are removed;
|
|
164
|
+
files you add yourself are kept.
|
|
165
|
+
|
|
166
|
+
Configure shared credentials with `new PracticeRtcClient({ query: async () =>
|
|
167
|
+
({ token: await refreshToken() }) })`. The provider runs for every connection.
|
|
168
|
+
Query values are merged in this order, with later sources taking precedence:
|
|
169
|
+
the `baseUrl` query string, `client.config.query`, `connect({ query: ... })`, then
|
|
170
|
+
named connect parameters such as `connect({ lessonId: "greetings", token: "new" })`.
|
|
171
|
+
Use wire aliases as keys inside query maps. An omitted named parameter preserves
|
|
172
|
+
shared values; `null` explicitly removes a query value. Required query parameters
|
|
173
|
+
are checked after merging. Generated query fields are optional so shared
|
|
174
|
+
credentials can supply them; required path parameters must still be passed.
|
|
175
|
+
|
|
176
|
+
## Security
|
|
177
|
+
|
|
178
|
+
Session admission is ordinary FastAPI dependency code, so authorization stays in
|
|
179
|
+
your application. Two WebSocket properties need attention:
|
|
180
|
+
|
|
181
|
+
- **Origins.** CORS does not apply to WebSocket handshakes, and browsers attach
|
|
182
|
+
cookies to cross-site handshakes. If admission relies on cookies, pass
|
|
183
|
+
`WebRtcRouter(allowed_origins=["https://app.example.com"])`. Handshakes from
|
|
184
|
+
other origins are refused with HTTP 403 before the session factory runs.
|
|
185
|
+
Requests without an `Origin` header come from non-browser clients and are not
|
|
186
|
+
restricted by this check.
|
|
187
|
+
- **Query tokens.** Browsers cannot set headers on a WebSocket, so tokens travel
|
|
188
|
+
in the URL. URLs can appear in proxy and server access logs. Prefer short-lived,
|
|
189
|
+
single-use session tickets issued by an authenticated HTTP endpoint over
|
|
190
|
+
long-lived access tokens, and supply them through the client's `query` provider.
|
|
191
|
+
|
|
192
|
+
## Limits and ICE
|
|
193
|
+
|
|
194
|
+
`SessionLimits` bounds negotiation time, pending candidates, signaling and
|
|
195
|
+
message sizes, inbound queues and send buffers. It also sets grace periods for
|
|
196
|
+
transport and signaling loss. Every limit has a categorized error.
|
|
197
|
+
`TurnRestCredentials` issues time-limited TURN REST credentials per user. It can
|
|
198
|
+
give the server-side engine internal relay URLs.
|
|
199
|
+
|
|
200
|
+
## Documentation
|
|
201
|
+
|
|
202
|
+
- [Signaling protocol](docs/protocol.md)
|
|
203
|
+
- [Canon composition and Dishka session/message scopes](docs/canon-dishka.md)
|
|
204
|
+
- [Changelog](CHANGELOG.md)
|
|
205
|
+
- Runnable example: [`examples/practice`](examples/practice). Its generated
|
|
206
|
+
client is in `examples/practice/client`.
|
|
207
|
+
|
|
208
|
+
The engine interface abstracts signaling and lifecycle. Native outgoing tracks
|
|
209
|
+
and received frames remain engine-specific: aiortc uses `MediaStreamTrack` and
|
|
210
|
+
PyAV frames. Its statistics return `jitter_seconds=None` because the public
|
|
211
|
+
report does not identify the per-stream codec clock rate needed for conversion.
|
|
212
|
+
|
|
213
|
+
Generated TypeScript models provide compile-time message types; the browser
|
|
214
|
+
runtime checks formats and sizes but does not enforce every JSON Schema rule.
|
|
215
|
+
Server-side Pydantic adapters validate incoming and outgoing typed messages.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Canon and Dishka
|
|
2
|
+
|
|
3
|
+
Register `WebRtcRouter` directly in `Feature.routers`. Canon installs Dishka's
|
|
4
|
+
middleware and manages the application container. WebRTC does not need a Canon
|
|
5
|
+
adapter or a required dependency on Canon or Dishka.
|
|
6
|
+
|
|
7
|
+
The session factory is a FastAPI dependency. Decorate an **async factory** with
|
|
8
|
+
`@inject` below `@rtc.session(...)`; Canon does not automatically inject this
|
|
9
|
+
factory. Session services use `Scope.SESSION`, matching Dishka's
|
|
10
|
+
[WebSocket scope model](https://dishka.readthedocs.io/en/stable/integrations/fastapi.html#websockets).
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from collections.abc import AsyncIterator
|
|
14
|
+
|
|
15
|
+
from dishka import AsyncContainer, FromDishka, Provider, Scope, provide
|
|
16
|
+
from dishka.integrations.fastapi import inject
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
from fastapi_canon import Composition, Feature
|
|
19
|
+
from fastapi_webrtc import DataChannelSpec, Peer, Session, WebRtcRouter
|
|
20
|
+
from fastapi_webrtc.engines.aiortc import AiortcEngine
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Transcript:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.messages: list[str] = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MessageWork:
|
|
29
|
+
def reply(self, text: str, transcript: Transcript) -> str:
|
|
30
|
+
transcript.messages.append(text)
|
|
31
|
+
return text.upper()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Services(Provider):
|
|
35
|
+
@provide(scope=Scope.SESSION)
|
|
36
|
+
async def transcript(self) -> AsyncIterator[Transcript]:
|
|
37
|
+
transcript = Transcript()
|
|
38
|
+
yield transcript
|
|
39
|
+
# Persist or release session resources here, after on_close.
|
|
40
|
+
|
|
41
|
+
@provide(scope=Scope.REQUEST)
|
|
42
|
+
async def work(self) -> AsyncIterator[MessageWork]:
|
|
43
|
+
yield MessageWork()
|
|
44
|
+
# Release this message's transaction or other short-lived resources.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
rtc = WebRtcRouter(engine=AiortcEngine())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
CHAT = DataChannelSpec("chat", receive_model=str, send_model=str)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@rtc.session("/chat", data_channels=[CHAT])
|
|
54
|
+
@inject
|
|
55
|
+
async def chat(
|
|
56
|
+
transcript: FromDishka[Transcript], container: FromDishka[AsyncContainer]
|
|
57
|
+
) -> Session:
|
|
58
|
+
async def on_message(peer: Peer, text: str) -> None:
|
|
59
|
+
async with container(scope=Scope.REQUEST) as request:
|
|
60
|
+
work = await request.get(MessageWork)
|
|
61
|
+
await peer.send_message(CHAT, work.reply(text, transcript))
|
|
62
|
+
|
|
63
|
+
return Session(on_message={CHAT: on_message})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
app = Composition(Feature(name="chat", routers=[rtc], providers=[Services])).apply(
|
|
67
|
+
FastAPI()
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Callbacks are called directly with the documented `Peer`, message, track or
|
|
72
|
+
close-code arguments. Capture session services in the factory's closures;
|
|
73
|
+
adding `FromDishka[...]` callback parameters does not inject them. For resources
|
|
74
|
+
per message, enter a new child `Scope.REQUEST` and resolve services explicitly,
|
|
75
|
+
as above. Await all work using those resources before leaving the child scope.
|
|
76
|
+
|
|
77
|
+
Accepted sessions call `on_close` while session services remain available.
|
|
78
|
+
Owned tracks and engine peers are then released before the session dependency
|
|
79
|
+
scope exits, including when the ASGI task is cancelled during cleanup. Canon
|
|
80
|
+
runs feature-router shutdown before closing feature resources and its
|
|
81
|
+
application container.
|
|
82
|
+
|
|
83
|
+
FastAPI authentication dependencies can deny admission before the factory runs.
|
|
84
|
+
Capacity and router-shutdown checks happen after factory resolution: if a
|
|
85
|
+
returned `Session` is refused, its owned `tracks` are stopped before dependency
|
|
86
|
+
teardown, without calling `on_close` or allocating an engine peer. Factories
|
|
87
|
+
must clean up resources they create if they raise before returning a `Session`.
|
|
88
|
+
|
|
89
|
+
The executable [integration tests](../../../tests/integration/test_webrtc_dishka.py)
|
|
90
|
+
cover HTTP admission denial, capacity rejection, disconnect, application
|
|
91
|
+
shutdown, cleanup order and two real aiortc data-channel messages with distinct
|
|
92
|
+
request scopes sharing one session service.
|