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.
Files changed (61) hide show
  1. fastapi_webrtc-0.1.0/.gitignore +5 -0
  2. fastapi_webrtc-0.1.0/CHANGELOG.md +51 -0
  3. fastapi_webrtc-0.1.0/LICENSE +21 -0
  4. fastapi_webrtc-0.1.0/PKG-INFO +245 -0
  5. fastapi_webrtc-0.1.0/README.md +215 -0
  6. fastapi_webrtc-0.1.0/docs/canon-dishka.md +92 -0
  7. fastapi_webrtc-0.1.0/docs/protocol.md +93 -0
  8. fastapi_webrtc-0.1.0/examples/__init__.py +0 -0
  9. fastapi_webrtc-0.1.0/examples/practice/__init__.py +1 -0
  10. fastapi_webrtc-0.1.0/examples/practice/app.py +167 -0
  11. fastapi_webrtc-0.1.0/examples/practice/client/.fastapi-webrtc.json +16 -0
  12. fastapi_webrtc-0.1.0/examples/practice/client/client.ts +113 -0
  13. fastapi_webrtc-0.1.0/examples/practice/client/index.ts +5 -0
  14. fastapi_webrtc-0.1.0/examples/practice/client/models.ts +21 -0
  15. fastapi_webrtc-0.1.0/examples/practice/client/runtime.ts +1160 -0
  16. fastapi_webrtc-0.1.0/examples/practice/contract.json +172 -0
  17. fastapi_webrtc-0.1.0/fastapi_webrtc/__init__.py +70 -0
  18. fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/__init__.py +1 -0
  19. fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/cli.py +117 -0
  20. fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/templates/runtime.ts +1160 -0
  21. fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/typescript.py +621 -0
  22. fastapi_webrtc-0.1.0/fastapi_webrtc/codegen/writer.py +73 -0
  23. fastapi_webrtc-0.1.0/fastapi_webrtc/contract.py +257 -0
  24. fastapi_webrtc-0.1.0/fastapi_webrtc/declarations.py +443 -0
  25. fastapi_webrtc-0.1.0/fastapi_webrtc/engine.py +244 -0
  26. fastapi_webrtc-0.1.0/fastapi_webrtc/engines/__init__.py +2 -0
  27. fastapi_webrtc-0.1.0/fastapi_webrtc/engines/aiortc.py +469 -0
  28. fastapi_webrtc-0.1.0/fastapi_webrtc/errors.py +120 -0
  29. fastapi_webrtc-0.1.0/fastapi_webrtc/events.py +52 -0
  30. fastapi_webrtc-0.1.0/fastapi_webrtc/ice.py +122 -0
  31. fastapi_webrtc-0.1.0/fastapi_webrtc/peer.py +230 -0
  32. fastapi_webrtc-0.1.0/fastapi_webrtc/protocol.py +160 -0
  33. fastapi_webrtc-0.1.0/fastapi_webrtc/py.typed +0 -0
  34. fastapi_webrtc-0.1.0/fastapi_webrtc/router.py +351 -0
  35. fastapi_webrtc-0.1.0/fastapi_webrtc/runtime.py +1280 -0
  36. fastapi_webrtc-0.1.0/fastapi_webrtc/sdp.py +92 -0
  37. fastapi_webrtc-0.1.0/package-lock.json +91 -0
  38. fastapi_webrtc-0.1.0/package.json +16 -0
  39. fastapi_webrtc-0.1.0/pyproject.toml +105 -0
  40. fastapi_webrtc-0.1.0/tests/__init__.py +0 -0
  41. fastapi_webrtc-0.1.0/tests/browser/index.html +9 -0
  42. fastapi_webrtc-0.1.0/tests/browser/page.ts +89 -0
  43. fastapi_webrtc-0.1.0/tests/browser/playwright.config.ts +41 -0
  44. fastapi_webrtc-0.1.0/tests/browser/practice.spec.ts +37 -0
  45. fastapi_webrtc-0.1.0/tests/browser/server.py +34 -0
  46. fastapi_webrtc-0.1.0/tests/browser/tsconfig.json +17 -0
  47. fastapi_webrtc-0.1.0/tests/contract_app.py +92 -0
  48. fastapi_webrtc-0.1.0/tests/engine/__init__.py +0 -0
  49. fastapi_webrtc-0.1.0/tests/engine/test_aiortc_sessions.py +244 -0
  50. fastapi_webrtc-0.1.0/tests/engine/test_aiortc_stats.py +43 -0
  51. fastapi_webrtc-0.1.0/tests/fake_engine.py +286 -0
  52. fastapi_webrtc-0.1.0/tests/peer_client.py +201 -0
  53. fastapi_webrtc-0.1.0/tests/server.py +32 -0
  54. fastapi_webrtc-0.1.0/tests/signaling.py +104 -0
  55. fastapi_webrtc-0.1.0/tests/test_codegen.py +657 -0
  56. fastapi_webrtc-0.1.0/tests/test_configuration.py +361 -0
  57. fastapi_webrtc-0.1.0/tests/test_contract.py +128 -0
  58. fastapi_webrtc-0.1.0/tests/test_runtime.py +1772 -0
  59. fastapi_webrtc-0.1.0/tests/typescript/fakes.ts +363 -0
  60. fastapi_webrtc-0.1.0/tests/typescript/runtime.test.ts +827 -0
  61. fastapi_webrtc-0.1.0/tests/typescript/tsconfig.json +18 -0
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ tests/typescript/dist/
3
+ tests/browser/dist/
4
+ tests/browser/results/
5
+ tests/browser/report/
@@ -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.