Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
92ad1df
Add live-status header icon, SSE backend, redesigned videos page
IEvangelist Apr 28, 2026
aefcb76
Add tests for live-status feature
IEvangelist Apr 28, 2026
71dca13
Unwrap draft PR body
IEvangelist Apr 28, 2026
baa6755
Remove draft PR body file
IEvangelist Apr 28, 2026
26e5f06
Remove worktree cleanup scripts
IEvangelist Apr 28, 2026
76a7ca8
Add local live-status dev controls
IEvangelist Apr 28, 2026
42ec296
Secure live dev commands and native PiP
IEvangelist Apr 28, 2026
3393635
Keep live PiP open across navigation
IEvangelist Apr 28, 2026
b1a45de
Build frontend directly into StaticHost
IEvangelist Apr 28, 2026
debb74b
Keep live PiP alive across site navigation
IEvangelist Apr 28, 2026
ecff707
Let users choose live PiP source
IEvangelist Apr 28, 2026
8cd2ce2
Polish live stream chooser
IEvangelist Apr 28, 2026
2060a2d
Fix live UX navigation regressions
IEvangelist Apr 28, 2026
1f8191d
Add live stream action dialog
IEvangelist Apr 29, 2026
07107cc
Fix live dialog mobile layout
IEvangelist Apr 29, 2026
9983182
Polish live dialog responsive behavior
IEvangelist Apr 29, 2026
2df10f9
Move mobile live dialog below header
IEvangelist Apr 29, 2026
bf50513
Polish live actions and notification sessions
IEvangelist Apr 30, 2026
d65fa4f
Fix compact header regression expectation
IEvangelist May 1, 2026
be60b2b
Add StaticHost live unit coverage
IEvangelist May 4, 2026
78df51a
Address live status PR feedback
IEvangelist May 11, 2026
0cecc2f
* PR feedback
eerhardt May 11, 2026
40902fa
Fix API reference search controllers under view transitions
IEvangelist May 11, 2026
921eae4
Address PR review feedback for live-status
IEvangelist Aug 18, 2026
b7f30c1
Fix botched merge in Header.astro cookie consent
IEvangelist Aug 18, 2026
4d9afb0
Add seoTitle to videos page to satisfy SEO length guard
IEvangelist Aug 18, 2026
a9595fb
Remove obsolete cookie-preferences SPA-nav e2e test
IEvangelist Aug 18, 2026
ae2856c
Harden live status production readiness
IEvangelist Aug 27, 2026
91e7803
Ignore generated Aspire publish output
IEvangelist Aug 27, 2026
efd7d10
Prefer provider handoff for mobile live streams
IEvangelist Aug 27, 2026
85a83ea
Update live command tests for Aspire 13.5
IEvangelist Sep 1, 2026
97b9f33
Use read-only site secrets and reduce live-status overhead
IEvangelist Sep 8, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ FodyWeavers.xsd

bin/
obj/
aspire-output/
tmp-ts-validation/
.playwright-mcp/

Expand Down
1 change: 1 addition & 0 deletions Aspire.Dev.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<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" />
Expand Down
13 changes: 12 additions & 1 deletion src/apphost/Aspire.Dev.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@

if (builder.ExecutionContext.IsRunMode)
{
// For local development: Use ViteApp for hot reload and development experience
staticHostWebsite.WithLocalLiveStatusDevCommands();

// For local development: Use ViteApp for hot reload and development experience.
// The live-status client calls same-origin /api/live[/stream]; inject StaticHost's
// origin so the Vite dev server can proxy those to the API (see astro.config.mjs).
// Without this, /api/live 404s against the Vite origin under `aspire run`.
builder.AddViteApp("frontend", "../../frontend")
.WithPnpm()
.WithEnvironment("ASPIRE_STATICHOST_URL", staticHostWebsite.GetEndpoint("https"))
.WithUrlForEndpoint("http", static url => url.DisplayText = "aspire.dev (Local)")
.WithExternalHttpEndpoints();
}
else
{
var siteConfig = builder.AddAzureKeyVault("siteconfig");
staticHostWebsite.WithProductionLiveStatus(siteConfig);
}

builder.Build().Run();
5 changes: 5 additions & 0 deletions src/apphost/Aspire.Dev.AppHost/Aspire.Dev.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Azure.AppService" Version="13.5.3" />
<PackageReference Include="Aspire.Hosting.Azure.FrontDoor" Version="13.5.3-preview.1.26425.3" />
<PackageReference Include="Aspire.Hosting.Azure.KeyVault" Version="13.5.3" />
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.5.3" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\statichost\StaticHost\StaticHost.csproj" />
</ItemGroup>

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

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

using Aspire.Hosting.Azure;
using Azure.Provisioning.KeyVault;

internal static class LiveExtensions
{
public static IResourceBuilder<ProjectResource> WithProductionLiveStatus(
this IResourceBuilder<ProjectResource> staticHostWebsite,
IResourceBuilder<AzureKeyVaultResource> siteConfig)
{
return staticHostWebsite
.WithRoleAssignments(siteConfig, KeyVaultBuiltInRole.KeyVaultSecretsUser)
.WithReference(siteConfig)
.WithEnvironment("Live__PublicBaseUrl", siteConfig.GetSecret("live-public-base-url"))
.WithEnvironment("Live__CoalesceWindowMs", siteConfig.GetSecret("live-coalesce-window-ms"))
.WithEnvironment("Live__Twitch__ClientId", siteConfig.GetSecret("live-twitch-client-id"))
.WithEnvironment("Live__Twitch__ClientSecret", siteConfig.GetSecret("live-twitch-client-secret"))
.WithEnvironment("Live__Twitch__WebhookSecret", siteConfig.GetSecret("live-twitch-webhook-secret"))
.WithEnvironment("Live__Twitch__ChannelLogin", siteConfig.GetSecret("live-twitch-channel-login"))
.WithEnvironment("Live__Twitch__ChannelId", siteConfig.GetSecret("live-twitch-channel-id"))
.WithEnvironment("Live__Twitch__ReconcileIntervalSeconds", siteConfig.GetSecret("live-twitch-reconcile-interval-seconds"))
.WithEnvironment("Live__YouTube__ApiKey", siteConfig.GetSecret("live-youtube-api-key"))
.WithEnvironment("Live__YouTube__WebhookSecret", siteConfig.GetSecret("live-youtube-webhook-secret"))
.WithEnvironment("Live__YouTube__ChannelHandle", siteConfig.GetSecret("live-youtube-channel-handle"))
.WithEnvironment("Live__YouTube__ChannelId", siteConfig.GetSecret("live-youtube-channel-id"))
.WithEnvironment("Live__YouTube__PollingIntervalSeconds", siteConfig.GetSecret("live-youtube-polling-interval-seconds"))
.WithEnvironment("Live__YouTube__DiscoveryPollingIntervalSeconds", siteConfig.GetSecret("live-youtube-discovery-polling-interval-seconds"))
.WithEnvironment("Live__YouTube__OfflineConfirmationCount", siteConfig.GetSecret("live-youtube-offline-confirmation-count"))
.PublishAsAzureAppServiceWebsite((_, website) =>
{
// Live state and WebSub verification are coordinated in memory.
website.SiteConfig.NumberOfWorkers = 1;
});
}

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));
}
}
27 changes: 27 additions & 0 deletions src/frontend/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,17 @@ import Icons from 'starlight-plugin-icons';

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

// Under `aspire run` the frontend dev server (Vite) and StaticHost are separate
// origins. The live-status client fetches same-origin `/api/live` and streams
// `/api/live/stream`, so in dev those must be proxied to StaticHost. The AppHost
// injects its origin as ASPIRE_STATICHOST_URL; unset in CI/production builds
// (where StaticHost serves both the site and the API from one origin), so the
// proxy is simply omitted then.
const staticHostUrl = process.env.ASPIRE_STATICHOST_URL;

// Astro renders pages mostly on the main JS thread. Default `build.concurrency`
// is 1, so a multi-vCPU CI runner is largely idle during the generate phase.
// Internal benchmarks on a 12k-page build showed:
Expand All @@ -44,6 +53,7 @@ const buildConcurrency = Number(process.env.ASPIRE_BUILD_CONCURRENCY) || 4;

// https://astro.build/config
export default defineConfig({
...(outDir ? { outDir } : {}),
prefetch: true,
site: 'https://aspire.dev',
trailingSlash: 'always',
Expand Down Expand Up @@ -232,4 +242,21 @@ export default defineConfig({
build: {
concurrency: buildConcurrency,
},
...(staticHostUrl
? {
vite: {
server: {
proxy: {
// A regular-expression context bypasses Astro's trailing-slash
// routing for both the JSON snapshot and SSE stream.
'^/api/live(?:/.*)?$': {
target: staticHostUrl,
changeOrigin: true,
secure: false,
},
},
},
},
}
: {}),
});
Loading
Loading