Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d06b22c
Add live-status header icon, SSE backend, redesigned videos page
IEvangelist Apr 28, 2026
cc3bf13
Add tests for live-status feature
IEvangelist Apr 28, 2026
14ccf17
Unwrap draft PR body
IEvangelist Apr 28, 2026
4c82288
Remove draft PR body file
IEvangelist Apr 28, 2026
a46cd4d
Remove worktree cleanup scripts
IEvangelist Apr 28, 2026
1b63d5c
Add local live-status dev controls
IEvangelist Apr 28, 2026
14cbdc6
Secure live dev commands and native PiP
IEvangelist Apr 28, 2026
22bf1aa
Keep live PiP open across navigation
IEvangelist Apr 28, 2026
e9193b4
Build frontend directly into StaticHost
IEvangelist Apr 28, 2026
3c656f0
Keep live PiP alive across site navigation
IEvangelist Apr 28, 2026
d6391d8
Let users choose live PiP source
IEvangelist Apr 28, 2026
93a4f60
Polish live stream chooser
IEvangelist Apr 28, 2026
46c9443
Fix live UX navigation regressions
IEvangelist Apr 28, 2026
ff63e7a
Add live stream action dialog
IEvangelist Apr 29, 2026
4e16994
Fix live dialog mobile layout
IEvangelist Apr 29, 2026
139e5eb
Polish live dialog responsive behavior
IEvangelist Apr 29, 2026
2f8602a
Move mobile live dialog below header
IEvangelist Apr 29, 2026
7d6ba42
Polish live actions and notification sessions
IEvangelist Apr 30, 2026
676cfd8
Fix compact header regression expectation
IEvangelist May 1, 2026
77732a5
Add StaticHost live unit coverage
IEvangelist May 4, 2026
d8e6b72
Address live status PR feedback
IEvangelist May 11, 2026
7c98e06
#758 PR feedback (#915)
eerhardt May 11, 2026
62cbb36
Fix API reference search controllers under view transitions
IEvangelist May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Aspire.Dev.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
<Project Path="src/statichost/StaticHost/StaticHost.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Aspire.Dev.AppHost.Tests/Aspire.Dev.AppHost.Tests.csproj" />
<Project Path="tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj" />
<Project Path="tests/PackageJsonGenerator.Tests/PackageJsonGenerator.Tests.csproj" />
<Project Path="tests/StaticHost.Tests/StaticHost.Tests.csproj" />
</Folder>
<Project Path="src/frontend/frontend.esproj">
<Build />
Expand Down
2 changes: 2 additions & 0 deletions src/apphost/Aspire.Dev.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

if (builder.ExecutionContext.IsRunMode)
{
staticHostWebsite.WithLocalLiveStatusDevCommands();

// For local development: Use ViteApp for hot reload and development experience
builder.AddViteApp("frontend", "../../frontend")
.WithPnpm()
Expand Down
4 changes: 4 additions & 0 deletions src/apphost/Aspire.Dev.AppHost/Aspire.Dev.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@
<ProjectReference Include="..\..\statichost\StaticHost\StaticHost.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Aspire.Dev.AppHost.Tests" />
</ItemGroup>

</Project>
261 changes: 261 additions & 0 deletions src/apphost/Aspire.Dev.AppHost/LiveExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
using System.Security.Cryptography;
using System.Text;

internal static class LiveExtensions
{
public static IResourceBuilder<ProjectResource> WithLocalLiveStatusDevCommands(this IResourceBuilder<ProjectResource> staticHostWebsite)
{
var liveDevCommandSecret = LiveDevCommands.NewSecret();
var liveDevTwitchWebhookSecret = LiveDevCommands.NewSecret();
var liveDevYouTubeWebhookSecret = LiveDevCommands.NewSecret();

return staticHostWebsite
// Local AppHost runs are the explicit live-status dev mode: external providers stay idle
// when no API credentials are configured, but dashboard commands can still exercise the
// UI and webhook paths with per-run local-only signing secrets.
.WithEnvironment("Live__EnableDevEndpoint", "true")
.WithEnvironment("Live__DevCommandSecret", liveDevCommandSecret)
.WithEnvironment("Live__Twitch__WebhookSecret", liveDevTwitchWebhookSecret)
.WithEnvironment("Live__YouTube__WebhookSecret", liveDevYouTubeWebhookSecret)
.WithUrlForEndpoint("http", static url => url.DisplayText = "aspire.dev (StaticHost)")
.WithLiveStatusUrls()
.WithLiveStatusCommands(
liveDevCommandSecret,
liveDevTwitchWebhookSecret,
liveDevYouTubeWebhookSecret);
}

private static IResourceBuilder<ProjectResource> WithLiveStatusUrls(this IResourceBuilder<ProjectResource> staticHostWebsite) =>
staticHostWebsite.WithUrls(ctx =>
{
if (ctx.Resource is not IResourceWithEndpoints withEndpoints)
{
return;
}

var endpoint = withEndpoints.GetEndpoint("http");
if (endpoint is null)
{
return;
}

ctx.Urls.Add(new() { Url = "/api/live", DisplayText = "Live status (JSON)", Endpoint = endpoint });
ctx.Urls.Add(new() { Url = "/api/live/stream", DisplayText = "Live status (SSE stream)", Endpoint = endpoint });
ctx.Urls.Add(new() { Url = "/api/live/twitch/webhook", DisplayText = "Twitch EventSub webhook (POST)", Endpoint = endpoint });
ctx.Urls.Add(new() { Url = "/api/live/youtube/webhook", DisplayText = "YouTube WebSub webhook (GET/POST)", Endpoint = endpoint });
ctx.Urls.Add(new() { Url = "/api/live/_dev/set", DisplayText = "Live dev override", Endpoint = endpoint });
ctx.Urls.Add(new() { Url = "/scalar/v1", DisplayText = "API reference (Scalar)", Endpoint = endpoint });
});

private static IResourceBuilder<ProjectResource> WithLiveStatusCommands(
this IResourceBuilder<ProjectResource> staticHostWebsite,
string liveDevCommandSecret,
string liveDevTwitchWebhookSecret,
string liveDevYouTubeWebhookSecret) =>
staticHostWebsite
.WithHttpCommand(
path: "/api/live/_dev/set",
displayName: "Live: all offline",
endpointName: "http",
commandName: "live-dev-all-offline",
commandOptions: LiveDevCommands.SetStatus(
liveDevCommandSecret,
"Turns off both local live-status sources.",
"""
{
"twitch": { "live": false, "channel": null, "title": null },
"youtube": { "live": false, "videoId": null }
}
""",
iconName: "LiveOff"))
.WithHttpCommand(
path: "/api/live/twitch/webhook",
displayName: "Simulate Twitch online webhook",
endpointName: "http",
commandName: "live-dev-twitch-online-webhook",
commandOptions: LiveDevCommands.TwitchWebhook(
liveDevCommandSecret,
liveDevTwitchWebhookSecret,
"Sends a signed stream.online notification to the local Twitch EventSub endpoint.",
"stream.online"))
.WithHttpCommand(
path: "/api/live/twitch/webhook",
displayName: "Simulate Twitch offline webhook",
endpointName: "http",
commandName: "live-dev-twitch-offline-webhook",
commandOptions: LiveDevCommands.TwitchWebhook(
liveDevCommandSecret,
liveDevTwitchWebhookSecret,
"Sends a signed stream.offline notification to the local Twitch EventSub endpoint.",
"stream.offline"))
.WithHttpCommand(
path: "/api/live/youtube/webhook",
displayName: "Simulate YouTube WebSub webhook",
endpointName: "http",
commandName: "live-dev-youtube-websub-webhook",
commandOptions: LiveDevCommands.YouTubeWebhook(
liveDevCommandSecret,
liveDevYouTubeWebhookSecret,
"Sends a signed Atom notification to the local YouTube WebSub endpoint. In dev mode without a YouTube API key, the payload directly sets YouTube live.",
videoId: "dev-live-video"))
.WithHttpCommand(
path: "/api/live/_dev/set",
displayName: "Live: YouTube offline",
endpointName: "http",
commandName: "live-dev-youtube-offline",
commandOptions: LiveDevCommands.SetStatus(
liveDevCommandSecret,
"Turns off only the local YouTube live-status source.",
"""
{
"youtube": { "live": false, "videoId": null }
}
""",
iconName: "VideoOff"))
.WithHttpCommand(
path: "/api/live/_dev/set",
displayName: "Live: both online",
endpointName: "http",
commandName: "live-dev-both-online",
commandOptions: LiveDevCommands.SetStatus(
liveDevCommandSecret,
"Turns on both local live-status sources without going through provider webhook validation.",
"""
{
"twitch": { "live": true, "channel": "aspiredotdev", "title": "Local dashboard test" },
"youtube": { "live": true, "videoId": "dev-live-video" }
}
""",
iconName: "Live"));
}

internal static class LiveDevCommands
{
public const string CommandSecretHeaderName = "X-Aspire-Live-Dev-Command-Key";

public static string NewSecret() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();

public static HttpCommandOptions SetStatus(
string commandSecret,
string description,
string body,
string iconName,
bool isHighlighted = false) =>
new()
{
Method = HttpMethod.Post,
Description = description,
IconName = iconName,
IconVariant = IconVariant.Regular,
IsHighlighted = isHighlighted,
PrepareRequest = context =>
{
AddCommandSecret(context, commandSecret);
context.Request.Content = Json(body, "application/json");
return Task.CompletedTask;
},
};

public static HttpCommandOptions TwitchWebhook(
string commandSecret,
string webhookSecret,
string description,
string subscriptionType) =>
new()
{
Method = HttpMethod.Post,
Description = description,
IconName = subscriptionType == "stream.online" ? "PlugConnected" : "PlugDisconnected",
IconVariant = IconVariant.Regular,
PrepareRequest = context =>
{
var body = $$"""
{
"subscription": { "type": "{{subscriptionType}}" },
"event": {
"broadcaster_user_id": "dev-aspire",
"broadcaster_user_login": "aspiredotdev",
"broadcaster_user_name": "Aspire",
"started_at": "{{DateTimeOffset.UtcNow:O}}"
}
}
""";

var messageId = Guid.NewGuid().ToString("N");
var timestamp = DateTimeOffset.UtcNow.ToString("O");
var bodyBytes = Encoding.UTF8.GetBytes(body);
var signature = SignTwitch(webhookSecret, messageId, timestamp, bodyBytes);

AddCommandSecret(context, commandSecret);
context.Request.Headers.Add("Twitch-Eventsub-Message-Id", messageId);
context.Request.Headers.Add("Twitch-Eventsub-Message-Timestamp", timestamp);
context.Request.Headers.Add("Twitch-Eventsub-Message-Type", "notification");
context.Request.Headers.Add("Twitch-Eventsub-Message-Signature", $"sha256={signature}");
context.Request.Content = Json(body, "application/json");
return Task.CompletedTask;
},
};

public static HttpCommandOptions YouTubeWebhook(
string commandSecret,
string webhookSecret,
string description,
string videoId) =>
new()
{
Method = HttpMethod.Post,
Description = description,
IconName = "ArrowSync",
IconVariant = IconVariant.Regular,
PrepareRequest = context =>
{
var body = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:yt="http://www.youtube.com/xml/schemas/2015">
<entry>
<yt:videoId>{{videoId}}</yt:videoId>
<title>Local Aspire live-status test</title>
<link rel="alternate" href="https://www.youtube.com/watch?v={{videoId}}" />
</entry>
</feed>
""";

var bodyBytes = Encoding.UTF8.GetBytes(body);
var signature = SignYouTube(webhookSecret, bodyBytes);
AddCommandSecret(context, commandSecret);
context.Request.Headers.Add("X-Hub-Signature", $"sha1={signature}");
context.Request.Content = Json(body, "application/atom+xml");
return Task.CompletedTask;
},
};

private static void AddCommandSecret(HttpCommandRequestContext context, string commandSecret)
{
if (string.IsNullOrEmpty(commandSecret))
{
throw new InvalidOperationException("A live-status dashboard command secret is required.");
}

context.Request.Headers.Add(CommandSecretHeaderName, $"Key: {commandSecret}");
}

private static StringContent Json(string body, string mediaType) =>
new(body, Encoding.UTF8, mediaType);

private static string SignTwitch(string secret, string messageId, string timestamp, byte[] body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var messageBytes = Encoding.UTF8.GetBytes(messageId);
hmac.TransformBlock(messageBytes, 0, messageBytes.Length, null, 0);
var timestampBytes = Encoding.UTF8.GetBytes(timestamp);
hmac.TransformBlock(timestampBytes, 0, timestampBytes.Length, null, 0);
hmac.TransformFinalBlock(body, 0, body.Length);
return Convert.ToHexStringLower(hmac.Hash!);
}

private static string SignYouTube(string secret, byte[] body)
{
using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(secret));
return Convert.ToHexStringLower(hmac.ComputeHash(body));
}
}
2 changes: 2 additions & 0 deletions src/frontend/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import jopSoftwarecookieconsent from '@jop-software/astro-cookieconsent';

const modeArgIndex = process.argv.indexOf('--mode');
const isSkipSearchBuild = modeArgIndex >= 0 && process.argv[modeArgIndex + 1] === 'skip-search';
const outDir = process.env.ASTRO_OUT_DIR;

// https://astro.build/config
export default defineConfig({
...(outDir ? { outDir } : {}),
prefetch: true,
site: 'https://aspire.dev',
trailingSlash: 'always',
Expand Down
32 changes: 16 additions & 16 deletions src/frontend/config/sidebar/community.topics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,23 +128,23 @@ export const communityTopics: StarlightSidebarTopicsUserConfig = {
],
},
{
label: 'Videos',
label: 'Live Streams',
translations: {
da: 'Videoer',
de: 'Videos',
en: 'Videos',
es: 'Videos',
fr: 'Vidéos',
hi: 'वीडियो',
id: 'Video',
it: 'Video',
ja: '動画',
ko: '비디오',
'pt-BR': 'Vídeos',
ru: 'Видео',
tr: 'Videolar',
uk: 'Відео',
'zh-CN': '视频',
da: 'Livestreams',
de: 'Livestreams',
en: 'Live Streams',
es: 'Transmisiones en directo',
fr: 'Diffusions en direct',
hi: 'लाइव स्ट्रीम',
id: 'Siaran langsung',
it: 'Dirette streaming',
ja: 'ライブ配信',
ko: '라이브 스트림',
'pt-BR': 'Transmissões ao vivo',
ru: 'Прямые эфиры',
tr: 'Canlı Yayınlar',
uk: 'Прямі трансляції',
'zh-CN': '直播',
},
slug: 'community/videos',
},
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"build": "pnpm git-env && astro build",
"build:skip-search": "pnpm git-env && astro build --mode skip-search",
"build:production": "pnpm git-env && astro build --mode production",
"build:statichost": "node ./scripts/build-static-host.mjs",
"build:statichost:skip-search": "node ./scripts/build-static-host.mjs --skip-search",
"preview": "astro preview",
"preview:host": "astro preview --host",
"astro": "pnpm git-env && astro",
Expand Down
Loading
Loading