|
| 1 | +//----------------------------------------------------------------------------- |
| 2 | +// Filename: Program.cs |
| 3 | +// |
| 4 | +// Description: An example for working with the Cloudflare Realtime SFU API. |
| 5 | +// This example is an ASP.NET app that: |
| 6 | +// 1. Publishes a test pattern video and audio stream to the SFU on startup. |
| 7 | +// 2. Serves a browser subscriber page (wwwroot/index.html). |
| 8 | +// 3. Proxies the Cloudflare "pull tracks" + "renegotiate" calls server-side so |
| 9 | +// the Cloudflare API token is NEVER sent to the browser, and the publisher |
| 10 | +// session id never needs to be copied and pasted. |
| 11 | +// |
| 12 | +// See: https://developers.cloudflare.com/realtime/sfu/https-api/ |
| 13 | +// API Reference: https://developers.cloudflare.com/realtime/static/realtime-api-2024-05-21.yaml |
| 14 | +// |
| 15 | +// To create the required Cloudflare Realtime SFU application and get the App ID and API token see: |
| 16 | +// https://developers.cloudflare.com/realtime/sfu/get-started/ |
| 17 | +// |
| 18 | +// Kiota (https://github.com/microsoft/kiota) codegen command for Cloudlfare realtime API client: |
| 19 | +// kiota generate -l CSharp -d https://developers.cloudflare.com/realtime/static/calls-api-2024-05-21.yaml -c RealtimeSfuClient -n Cloudflare.Realtime.Sfu -o ./RealtimeSfu --exclude-backward-compatible --clean-output |
| 20 | +// |
| 21 | +// Author(s): |
| 22 | +// Aaron Clauson (aaron@sipsorcery.com) |
| 23 | +// |
| 24 | +// History: |
| 25 | +// 28 May 2026 Aaron Clauson Created, Dublin, Ireland. |
| 26 | +// |
| 27 | +// License: |
| 28 | +// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file. |
| 29 | +//----------------------------------------------------------------------------- |
| 30 | + |
| 31 | +using System; |
| 32 | +using System.Collections.Generic; |
| 33 | +using System.Linq; |
| 34 | +using System.Net.Http; |
| 35 | +using System.Threading; |
| 36 | +using System.Threading.Tasks; |
| 37 | +using Cloudflare.Realtime.Sfu; |
| 38 | +using Cloudflare.Realtime.Sfu.Models; |
| 39 | +using Microsoft.AspNetCore.Builder; |
| 40 | +using Microsoft.AspNetCore.Hosting; |
| 41 | +using Microsoft.AspNetCore.Http; |
| 42 | +using Microsoft.Extensions.DependencyInjection; |
| 43 | +using Microsoft.Extensions.Hosting; |
| 44 | +using Microsoft.Extensions.Logging; |
| 45 | +using Microsoft.Kiota.Abstractions.Authentication; |
| 46 | +using Microsoft.Kiota.Http.HttpClientLibrary; |
| 47 | +using Serilog; |
| 48 | +using Serilog.Extensions.Logging; |
| 49 | +using SIPSorcery.Media; |
| 50 | +using SIPSorcery.Net; |
| 51 | +using SIPSorcery.Sys; |
| 52 | +using Vpx.Net; |
| 53 | + |
| 54 | +const string ListenUrl = "http://localhost:8080"; |
| 55 | + |
| 56 | +var cloudflareAppID = Environment.GetEnvironmentVariable("CLOUDFLARE_APPID"); |
| 57 | +var cloudflareAPIToken = Environment.GetEnvironmentVariable("CLOUDFLARE_API_TOKEN"); |
| 58 | + |
| 59 | +if (string.IsNullOrWhiteSpace(cloudflareAppID) || string.IsNullOrWhiteSpace(cloudflareAPIToken)) |
| 60 | +{ |
| 61 | + Console.Error.WriteLine("Please set the CLOUDFLARE_APPID and CLOUDFLARE_API_TOKEN environment variables."); |
| 62 | + return; |
| 63 | +} |
| 64 | + |
| 65 | +// Route the SIPSorcery library logs and the ASP.NET host logs through the same Serilog console sink. |
| 66 | +var seriLogger = new LoggerConfiguration() |
| 67 | + .Enrich.FromLogContext() |
| 68 | + .MinimumLevel.Is(Serilog.Events.LogEventLevel.Debug) |
| 69 | + .WriteTo.Console() |
| 70 | + .CreateLogger(); |
| 71 | +SIPSorcery.LogFactory.Set(new SerilogLoggerFactory(seriLogger)); |
| 72 | + |
| 73 | +var builder = WebApplication.CreateBuilder(args); |
| 74 | +builder.WebHost.UseUrls(ListenUrl); |
| 75 | +builder.Logging.ClearProviders(); |
| 76 | +builder.Logging.AddSerilog(seriLogger); |
| 77 | + |
| 78 | +// The publisher service holds the live Cloudflare session and the SFU API client (with the token). |
| 79 | +// It is registered as both a singleton (so the endpoints can use it) and a hosted service (so it |
| 80 | +// starts publishing when the app starts and tears down the session when the app stops). |
| 81 | +builder.Services.AddSingleton(sp => new CloudflareSfuService( |
| 82 | + sp.GetRequiredService<ILogger<CloudflareSfuService>>(), |
| 83 | + cloudflareAppID!, |
| 84 | + cloudflareAPIToken!)); |
| 85 | +builder.Services.AddHostedService(sp => sp.GetRequiredService<CloudflareSfuService>()); |
| 86 | + |
| 87 | +var app = builder.Build(); |
| 88 | + |
| 89 | +app.UseDefaultFiles(); |
| 90 | +app.UseStaticFiles(); |
| 91 | + |
| 92 | +// Returns non-secret publisher info for display in the page. The token and the raw SFU |
| 93 | +// API are never exposed to the browser. |
| 94 | +app.MapGet("/api/publisher", (CloudflareSfuService sfu) => Results.Json(new |
| 95 | +{ |
| 96 | + sessionId = sfu.PublisherSessionId, |
| 97 | + audioTrackName = sfu.AudioTrackName, |
| 98 | + videoTrackName = sfu.VideoTrackName |
| 99 | +})); |
| 100 | + |
| 101 | +// Creates a subscriber session and pulls the publisher's remote tracks. Cloudflare generates |
| 102 | +// the offer for pulled tracks, which we hand back to the browser to answer. |
| 103 | +app.MapPost("/api/subscribe", async (CloudflareSfuService sfu) => |
| 104 | +{ |
| 105 | + var (subscriberSessionId, sdp) = await sfu.SubscribeAsync(); |
| 106 | + return Results.Json(new { subscriberSessionId, sdp }); |
| 107 | +}); |
| 108 | + |
| 109 | +// Forwards the browser's answer SDP to Cloudflare to complete the pulled-track negotiation. |
| 110 | +app.MapPost("/api/renegotiate", async (CloudflareSfuService sfu, RenegotiateBody body) => |
| 111 | +{ |
| 112 | + await sfu.RenegotiateAsync(body.SubscriberSessionId, body.Sdp); |
| 113 | + return Results.Ok(); |
| 114 | +}); |
| 115 | + |
| 116 | +Console.WriteLine($"Cloudflare WebRTC SFU example. Browse to {ListenUrl} once publishing has started."); |
| 117 | + |
| 118 | +app.Run(); |
| 119 | + |
| 120 | +/// <summary> |
| 121 | +/// Body for the /api/renegotiate endpoint. Bound from the browser's JSON request |
| 122 | +/// (property matching is case-insensitive, so subscriberSessionId/sdp map across). |
| 123 | +/// </summary> |
| 124 | +record RenegotiateBody(string SubscriberSessionId, string Sdp); |
| 125 | + |
| 126 | +/// <summary> |
| 127 | +/// Hosts the publisher peer connection and proxies the Cloudflare SFU calls. Keeping all |
| 128 | +/// Cloudflare interaction here means the API token stays server-side. |
| 129 | +/// </summary> |
| 130 | +sealed class CloudflareSfuService : IHostedService |
| 131 | +{ |
| 132 | + private const string STUN_URL = "stun:stun.cloudflare.com"; |
| 133 | + |
| 134 | + private readonly ILogger<CloudflareSfuService> _logger; |
| 135 | + private readonly Cloudflare.Realtime.Sfu.Apps.Item.Sessions.SessionsRequestBuilder _sessions; |
| 136 | + |
| 137 | + private RTCPeerConnection? _publisherPc; |
| 138 | + private VideoTestPatternSource? _videoSource; |
| 139 | + private AudioExtrasSource? _audioSource; |
| 140 | + |
| 141 | + public string? PublisherSessionId { get; private set; } |
| 142 | + public string AudioTrackName => "test-audio"; |
| 143 | + public string VideoTrackName => "test-pattern"; |
| 144 | + |
| 145 | + public CloudflareSfuService(ILogger<CloudflareSfuService> logger, string appId, string apiToken) |
| 146 | + { |
| 147 | + _logger = logger; |
| 148 | + |
| 149 | + var authProvider = new BaseBearerTokenAuthenticationProvider(new StaticAccessTokenProvider(apiToken)); |
| 150 | + var httpLoggingHandler = new HttpLoggingHandler(SIPSorcery.LogFactory.CreateLogger<HttpLoggingHandler>()) |
| 151 | + { |
| 152 | + InnerHandler = new HttpClientHandler() |
| 153 | + }; |
| 154 | + var httpClient = new HttpClient(httpLoggingHandler); |
| 155 | + var requestAdapter = new HttpClientRequestAdapter(authProvider, null, null, httpClient); |
| 156 | + var client = new RealtimeSfuClient(requestAdapter); |
| 157 | + _sessions = client.Apps[appId].Sessions; |
| 158 | + } |
| 159 | + |
| 160 | + /// <summary> |
| 161 | + /// Creates the publisher session and pushes the local audio/video tracks to Cloudflare. |
| 162 | + /// </summary> |
| 163 | + public async Task StartAsync(CancellationToken cancellationToken) |
| 164 | + { |
| 165 | + var newSessionResponse = await _sessions.New.PostAsync(cancellationToken: cancellationToken); |
| 166 | + PublisherSessionId = newSessionResponse?.SessionId; |
| 167 | + _logger.LogInformation("Created publisher session {SessionId}.", PublisherSessionId); |
| 168 | + |
| 169 | + _publisherPc = CreatePublisherPeerConnection(); |
| 170 | + var offer = _publisherPc.createOffer(); |
| 171 | + await _publisherPc.setLocalDescription(offer); |
| 172 | + |
| 173 | + var newTrackResponse = await _sessions[PublisherSessionId!].Tracks.New.PostAsync(new TracksRequest |
| 174 | + { |
| 175 | + SessionDescription = new SessionDescription |
| 176 | + { |
| 177 | + Type = SessionDescription_type.Offer, |
| 178 | + Sdp = offer.sdp |
| 179 | + }, |
| 180 | + Tracks = new List<TrackObject> |
| 181 | + { |
| 182 | + new() { Location = TrackObject_location.Local, Mid = "0", Kind = "audio", TrackName = AudioTrackName }, |
| 183 | + new() { Location = TrackObject_location.Local, Mid = "1", Kind = "video", TrackName = VideoTrackName } |
| 184 | + } |
| 185 | + }, cancellationToken: cancellationToken); |
| 186 | + |
| 187 | + var answer = new RTCSessionDescriptionInit { sdp = newTrackResponse?.SessionDescription?.Sdp, type = RTCSdpType.answer }; |
| 188 | + _publisherPc.setRemoteDescription(answer); |
| 189 | + |
| 190 | + _logger.LogInformation("Publisher tracks pushed for session {SessionId}.", PublisherSessionId); |
| 191 | + } |
| 192 | + |
| 193 | + /// <summary> |
| 194 | + /// Gracefully tears down the publisher session. The SFU API has no explicit "delete session" |
| 195 | + /// call; closing its tracks (force = true, so no WebRTC renegotiation is required) stops the |
| 196 | + /// media flow and lets Cloudflare reclaim the session once the transport drops. |
| 197 | + /// </summary> |
| 198 | + public async Task StopAsync(CancellationToken cancellationToken) |
| 199 | + { |
| 200 | + await CloseSessionAsync(PublisherSessionId); |
| 201 | + _publisherPc?.Close("server shutdown"); |
| 202 | + } |
| 203 | + |
| 204 | + /// <summary> |
| 205 | + /// Creates a subscriber session and pulls the publisher's remote tracks. Returns the |
| 206 | + /// subscriber session id and the offer SDP that Cloudflare generated for the pulled tracks. |
| 207 | + /// </summary> |
| 208 | + public async Task<(string SubscriberSessionId, string? Sdp)> SubscribeAsync() |
| 209 | + { |
| 210 | + var subscriber = await _sessions.New.PostAsync(); |
| 211 | + var subscriberSessionId = subscriber?.SessionId |
| 212 | + ?? throw new InvalidOperationException("Cloudflare did not return a subscriber session id."); |
| 213 | + |
| 214 | + // For remote (pull) tracks no offer is sent; Cloudflare adds the m-lines server-side and |
| 215 | + // returns an offer that the browser must answer. |
| 216 | + var pull = await _sessions[subscriberSessionId].Tracks.New.PostAsync(new TracksRequest |
| 217 | + { |
| 218 | + Tracks = new List<TrackObject> |
| 219 | + { |
| 220 | + new() { Location = TrackObject_location.Remote, SessionId = PublisherSessionId, TrackName = AudioTrackName }, |
| 221 | + new() { Location = TrackObject_location.Remote, SessionId = PublisherSessionId, TrackName = VideoTrackName } |
| 222 | + } |
| 223 | + }); |
| 224 | + |
| 225 | + _logger.LogInformation("Subscriber session {SubscriberSessionId} pulling from publisher {PublisherSessionId}.", |
| 226 | + subscriberSessionId, PublisherSessionId); |
| 227 | + |
| 228 | + return (subscriberSessionId, pull?.SessionDescription?.Sdp); |
| 229 | + } |
| 230 | + |
| 231 | + /// <summary> |
| 232 | + /// Forwards the browser's answer SDP to Cloudflare to complete the pulled-track negotiation. |
| 233 | + /// </summary> |
| 234 | + public async Task RenegotiateAsync(string subscriberSessionId, string sdp) |
| 235 | + { |
| 236 | + await _sessions[subscriberSessionId].Renegotiate.PutAsync(new RenegotiateRequest |
| 237 | + { |
| 238 | + SessionDescription = new SessionDescription |
| 239 | + { |
| 240 | + Type = SessionDescription_type.Answer, |
| 241 | + Sdp = sdp |
| 242 | + } |
| 243 | + }); |
| 244 | + |
| 245 | + _logger.LogInformation("Renegotiated subscriber session {SubscriberSessionId}.", subscriberSessionId); |
| 246 | + } |
| 247 | + |
| 248 | + private async Task CloseSessionAsync(string? sessionId) |
| 249 | + { |
| 250 | + if (string.IsNullOrWhiteSpace(sessionId)) |
| 251 | + { |
| 252 | + return; |
| 253 | + } |
| 254 | + |
| 255 | + try |
| 256 | + { |
| 257 | + var session = _sessions[sessionId]; |
| 258 | + |
| 259 | + var state = await session.GetAsync(); |
| 260 | + var mids = state?.Tracks? |
| 261 | + .Where(t => !string.IsNullOrWhiteSpace(t.Mid)) |
| 262 | + .Select(t => t.Mid!) |
| 263 | + .Distinct() |
| 264 | + .ToList() ?? new List<string>(); |
| 265 | + |
| 266 | + if (mids.Count == 0) |
| 267 | + { |
| 268 | + _logger.LogInformation("Session {SessionId} has no open tracks to close.", sessionId); |
| 269 | + return; |
| 270 | + } |
| 271 | + |
| 272 | + _logger.LogInformation("Closing {Count} track(s) for session {SessionId}.", mids.Count, sessionId); |
| 273 | + |
| 274 | + await session.Tracks.Close.PutAsync(new CloseTracksRequest |
| 275 | + { |
| 276 | + Force = true, |
| 277 | + Tracks = mids.Select(mid => new CloseTrackObject { Mid = mid }).ToList() |
| 278 | + }); |
| 279 | + |
| 280 | + _logger.LogInformation("Closed tracks for session {SessionId}.", sessionId); |
| 281 | + } |
| 282 | + catch (Exception ex) |
| 283 | + { |
| 284 | + _logger.LogWarning("Failed to close Cloudflare session {SessionId}: {Message}", sessionId, ex.Message); |
| 285 | + } |
| 286 | + } |
| 287 | + |
| 288 | + private RTCPeerConnection CreatePublisherPeerConnection() |
| 289 | + { |
| 290 | + RTCConfiguration config = new RTCConfiguration |
| 291 | + { |
| 292 | + iceServers = new List<RTCIceServer> { new RTCIceServer { urls = STUN_URL } } |
| 293 | + }; |
| 294 | + var pc = new RTCPeerConnection(config); |
| 295 | + |
| 296 | + var vp8Codec = new VP8Codec(); |
| 297 | + _videoSource = new VideoTestPatternSource(vp8Codec); |
| 298 | + _audioSource = new AudioExtrasSource(new AudioEncoder(), new AudioSourceOptions { AudioSource = AudioSourcesEnum.Music }); |
| 299 | + |
| 300 | + MediaStreamTrack videoTrack = new MediaStreamTrack(_videoSource.GetVideoSourceFormats(), MediaStreamStatusEnum.SendRecv); |
| 301 | + pc.addTrack(videoTrack); |
| 302 | + MediaStreamTrack audioTrack = new MediaStreamTrack(_audioSource.GetAudioSourceFormats(), MediaStreamStatusEnum.SendRecv); |
| 303 | + pc.addTrack(audioTrack); |
| 304 | + |
| 305 | + _videoSource.OnVideoSourceEncodedSample += pc.SendVideo; |
| 306 | + _audioSource.OnAudioSourceEncodedSample += pc.SendAudio; |
| 307 | + |
| 308 | + pc.OnVideoFormatsNegotiated += (formats) => _videoSource.SetVideoSourceFormat(formats.First()); |
| 309 | + pc.OnAudioFormatsNegotiated += (formats) => _audioSource.SetAudioSourceFormat(formats.First()); |
| 310 | + |
| 311 | + pc.onconnectionstatechange += async (state) => |
| 312 | + { |
| 313 | + _logger.LogDebug("Publisher peer connection state change to {State}.", state); |
| 314 | + |
| 315 | + if (state == RTCPeerConnectionState.connected) |
| 316 | + { |
| 317 | + await _audioSource.StartAudio(); |
| 318 | + await _videoSource.StartVideo(); |
| 319 | + } |
| 320 | + else if (state == RTCPeerConnectionState.failed) |
| 321 | + { |
| 322 | + pc.Close("ice disconnection"); |
| 323 | + } |
| 324 | + else if (state == RTCPeerConnectionState.closed) |
| 325 | + { |
| 326 | + await _videoSource.CloseVideo(); |
| 327 | + await _audioSource.CloseAudio(); |
| 328 | + } |
| 329 | + }; |
| 330 | + |
| 331 | + pc.oniceconnectionstatechange += (state) => _logger.LogDebug("Publisher ICE connection state change to {State}.", state); |
| 332 | + |
| 333 | + return pc; |
| 334 | + } |
| 335 | +} |
| 336 | + |
| 337 | +/// <summary> |
| 338 | +/// Supplies the static Cloudflare bearer token to the Kiota request adapter. |
| 339 | +/// </summary> |
| 340 | +sealed class StaticAccessTokenProvider : IAccessTokenProvider |
| 341 | +{ |
| 342 | + private readonly string _accessToken; |
| 343 | + |
| 344 | + public StaticAccessTokenProvider(string accessToken) |
| 345 | + { |
| 346 | + _accessToken = accessToken; |
| 347 | + AllowedHostsValidator = new AllowedHostsValidator(new[] { "rtc.live.cloudflare.com" }); |
| 348 | + } |
| 349 | + |
| 350 | + public AllowedHostsValidator AllowedHostsValidator { get; } |
| 351 | + |
| 352 | + public Task<string> GetAuthorizationTokenAsync( |
| 353 | + Uri uri, |
| 354 | + Dictionary<string, object>? additionalAuthenticationContext = default, |
| 355 | + CancellationToken cancellationToken = default) |
| 356 | + { |
| 357 | + return Task.FromResult(_accessToken); |
| 358 | + } |
| 359 | +} |
0 commit comments