Skip to content

Commit 66fb094

Browse files
committed
Added demo app for Cloudflare's hosted WebRTC SFU service.
1 parent 07928fd commit 66fb094

73 files changed

Lines changed: 5141 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<LangVersion>latest</LangVersion>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.Kiota.Bundle" Version="2.0.0" />
11+
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
12+
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<ProjectReference Include="..\..\..\src\SIPSorcery\SIPSorcery.csproj" />
17+
<ProjectReference Include="..\..\..\src\SIPSorcery.VP8\SIPSorcery.VP8.csproj" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
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+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"profiles": {
3+
"CloudflareSfu": {
4+
"commandName": "Project",
5+
"launchBrowser": true,
6+
"environmentVariables": {
7+
"ASPNETCORE_ENVIRONMENT": "Development"
8+
},
9+
"applicationUrl": "https://localhost:63333;http://localhost:63334"
10+
}
11+
}
12+
}

0 commit comments

Comments
 (0)