From a43a2102b6477119d1424c2c60d05f2e18b8d84a Mon Sep 17 00:00:00 2001 From: David Pine Date: Mon, 27 Apr 2026 21:53:56 -0500 Subject: [PATCH 01/20] Add live-status header icon, SSE backend, redesigned videos page - Adds a strobing live-status indicator in the site header (left of the cookie-preferences button) wired to a new /api/live SSE endpoint. - Adds a custom floating PiP player that follows visitors across the site while live and returns them to the videos page on close. - Replaces the legacy curated /community/videos/ page with a focused YouTube + Twitch tabbed page whose embed lights up when live. - Adds an in-StaticHost background-worker stack: * Twitch EventSub stream.online/offline subscription + reconcile, * YouTube WebSub subscribe/renew + confirming poll fallback, * a debounced LiveStatusBroadcaster with sticky primary-source mesh logic and Channel SSE fan-out. - Surfaces the new endpoints + Scalar API reference (with a custom Aspire-brand theme) on the Aspire dashboard for local dev. - Includes a worktree cleanup helper script and PR_BODY.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/PR_BODY.md | 111 +++++++ scripts/cleanup-worktree.ps1 | 53 +++ scripts/cleanup-worktree.sh | 32 ++ src/apphost/Aspire.Dev.AppHost/AppHost.cs | 20 ++ src/frontend/src/assets/icons/live.svg | 1 + src/frontend/src/components/LivePip.astro | 254 +++++++++++++++ .../src/components/LiveVideosTabs.astro | 150 +++++++++ src/frontend/src/components/live-status.ts | 203 ++++++++++++ .../src/components/starlight/Header.astro | 83 +++++ .../src/content/docs/community/videos.mdx | 257 +-------------- src/statichost/StaticHost/GlobalUsings.cs | 1 + .../StaticHost/Live/LiveEndpoints.cs | 303 ++++++++++++++++++ src/statichost/StaticHost/Live/LiveStatus.cs | 51 +++ .../StaticHost/Live/LiveStatusBroadcaster.cs | 205 ++++++++++++ .../StaticHost/Live/LiveStatusOptions.cs | 113 +++++++ .../LiveStatusServiceCollectionExtensions.cs | 60 ++++ src/statichost/StaticHost/Live/README.md | 145 +++++++++ .../StaticHost/Live/Twitch/ITwitchClient.cs | 29 ++ .../Live/Twitch/TwitchAppTokenProvider.cs | 81 +++++ .../StaticHost/Live/Twitch/TwitchClient.cs | 125 ++++++++ .../Live/Twitch/TwitchEventSubService.cs | 134 ++++++++ .../Live/Twitch/TwitchWebhookHandler.cs | 87 +++++ .../StaticHost/Live/YouTube/IYouTubeClient.cs | 25 ++ .../StaticHost/Live/YouTube/YouTubeClient.cs | 102 ++++++ .../Live/YouTube/YouTubeWebSubService.cs | 127 ++++++++ .../Live/YouTube/YouTubeWebhookHandler.cs | 51 +++ src/statichost/StaticHost/Program.cs | 25 ++ src/statichost/StaticHost/StaticHost.csproj | 3 + src/statichost/StaticHost/appsettings.json | 23 +- .../wwwroot/scalar/aspire-theme.css | 67 ++++ 30 files changed, 2670 insertions(+), 251 deletions(-) create mode 100644 .github/PR_BODY.md create mode 100644 scripts/cleanup-worktree.ps1 create mode 100644 scripts/cleanup-worktree.sh create mode 100644 src/frontend/src/assets/icons/live.svg create mode 100644 src/frontend/src/components/LivePip.astro create mode 100644 src/frontend/src/components/LiveVideosTabs.astro create mode 100644 src/frontend/src/components/live-status.ts create mode 100644 src/statichost/StaticHost/Live/LiveEndpoints.cs create mode 100644 src/statichost/StaticHost/Live/LiveStatus.cs create mode 100644 src/statichost/StaticHost/Live/LiveStatusBroadcaster.cs create mode 100644 src/statichost/StaticHost/Live/LiveStatusOptions.cs create mode 100644 src/statichost/StaticHost/Live/LiveStatusServiceCollectionExtensions.cs create mode 100644 src/statichost/StaticHost/Live/README.md create mode 100644 src/statichost/StaticHost/Live/Twitch/ITwitchClient.cs create mode 100644 src/statichost/StaticHost/Live/Twitch/TwitchAppTokenProvider.cs create mode 100644 src/statichost/StaticHost/Live/Twitch/TwitchClient.cs create mode 100644 src/statichost/StaticHost/Live/Twitch/TwitchEventSubService.cs create mode 100644 src/statichost/StaticHost/Live/Twitch/TwitchWebhookHandler.cs create mode 100644 src/statichost/StaticHost/Live/YouTube/IYouTubeClient.cs create mode 100644 src/statichost/StaticHost/Live/YouTube/YouTubeClient.cs create mode 100644 src/statichost/StaticHost/Live/YouTube/YouTubeWebSubService.cs create mode 100644 src/statichost/StaticHost/Live/YouTube/YouTubeWebhookHandler.cs create mode 100644 src/statichost/StaticHost/wwwroot/scalar/aspire-theme.css diff --git a/.github/PR_BODY.md b/.github/PR_BODY.md new file mode 100644 index 000000000..9fd97de88 --- /dev/null +++ b/.github/PR_BODY.md @@ -0,0 +1,111 @@ +# Live status header icon + redesigned videos page + +Adds a real-time **live indicator** to the aspire.dev site header (left of the +cookie-preferences button) that strobes whenever the team is broadcasting on +YouTube and/or Twitch, and replaces the legacy `/community/videos/` curated +list with a focused two-tab page (YouTube / Twitch) whose embed lights up +when the corresponding stream goes live. + +While live, a small floating "PiP" player follows the visitor across the +site (closing it returns them to the videos page). State is pushed in real +time over Server-Sent Events from a resilient ASP.NET Core background +worker that combines Twitch EventSub + YouTube WebSub webhooks with +confirming polls. + +## What's added + +### Frontend + +- `src/frontend/src/assets/icons/live.svg` — broadcast/radio-waves motif, + matches `cookies.svg` shape so it inherits accent color via `currentColor`. +- `src/frontend/src/components/starlight/Header.astro` — new `.live-btn` + rendered before `.cookie-consent-btn` in **both** desktop and mobile + right-groups, with `live-pulse` keyframes and a `prefers-reduced-motion` + fallback. Globally mounts `LivePip` and imports the live-status client. +- `src/frontend/src/components/live-status.ts` — singleton SSE client + (re-entrant, exponential backoff, `visibilitychange`-aware reconnect). + Dispatches typed `aspire:live-change` `CustomEvent`s on `document`. +- `src/frontend/src/components/LivePip.astro` — custom floating panel + (deliberately **not** the Document PiP API — cross-origin embeds don't + survive transfer). Hidden until live; closes back to the videos page; + remembers per-session dismissal until the next live cycle. +- `src/frontend/src/content/docs/community/videos.mdx` — replaces the + giant curated `aspireifridays`/`dotnetConf2025`/`communityVideos` arrays + with two tabs. The active tab auto-switches to whichever source is + primary, embeds get a `LIVE` badge + glow, and tab clicks become sticky + (no auto-yanking once the user has chosen). + +### Backend (`src/statichost/StaticHost/Live/`) + +- `LiveStatusOptions.cs` + `LiveStatus.cs` — strongly-typed options + + records (with `JsonSerializerContext` source generation). +- `LiveStatusBroadcaster.cs` — singleton state + `Channel` fan-out + + 750 ms coalesce window. Sticky `primarySource` so the UI doesn't flap. +- `LiveEndpoints.cs` — `MapLiveStatus()` extension. Mounts + `GET /api/live`, `GET /api/live/stream` (SSE), the Twitch + YouTube + webhooks, and a Dev-only `POST /api/live/_dev/set` for local testing. +- `LiveStatusServiceCollectionExtensions.cs` — `builder.AddLiveStatus()`: + options binding, named resilient HttpClients (`twitch`, `twitch-id`, + `youtube`, `youtube-pubsub`), both background services. +- `Twitch/` — Helix client + app-token provider (proactive 5-min refresh) + + EventSub reconcile worker + pure HMAC-SHA256 webhook handler. +- `Yo­uTube/` — Data API client + WebSub subscribe/renew worker + (5-day lease, renew at 4 days) + pure HMAC-SHA1 webhook handler with a + confirming-poll guard so VOD uploads don't fake a live state. + +### AppHost / Scalar + +- `Aspire.Dev.AppHost/AppHost.cs` — labelled custom dashboard URLs for the + live API + the Scalar reference, surfaced on the `aspiredev` resource. +- Adds `Microsoft.AspNetCore.OpenApi` + `Scalar.AspNetCore`. In + Development, `MapScalarApiReference("/scalar/v1")` is wired with a + custom Aspire-brand theme at `wwwroot/scalar/aspire-theme.css`. + +## How to test locally + +The Twitch/YouTube credentials are non-fatal: if they're missing, the +workers log a warning and stay idle, and the SSE endpoint returns +`{ isLive: false }` — the site ships in any state. To exercise the UI +without provisioning real webhooks, use the Dev-only override (gated on +`IsDevelopment` AND `Live:EnableDevEndpoint=true`): + +```powershell +# Force "live on Twitch" (header icon strobes, PiP appears): +curl -Method POST http://localhost:5000/api/live/_dev/set ` + -ContentType 'application/json' ` + -Body '{ "twitch": true, "twitchChannel": "aspiredotdev" }' + +# Watch the stream: +curl -N http://localhost:5000/api/live/stream +``` + +A Playwright spec exercises the full UX with mocked `/api/live` + +streamed SSE chunks. + +## Configuration + +```json +"Live": { + "Twitch": { "ClientId": "", "ClientSecret": "", "WebhookSecret": "", "ChannelLogin": "aspiredotdev" }, + "YouTube": { "ApiKey": "", "WebhookSecret": "", "ChannelHandle": "@aspiredotdev" } +} +``` + +User-secrets in dev, env vars / Key Vault in prod. See +`src/statichost/StaticHost/Live/README.md` for the full architecture +overview, mesh logic, and ops runbook. + +## Screenshots + +_TODO: drop in once final icon CSS is reviewed._ + +## Resilience checklist + +- Named `HttpClient` instances with `AddStandardResilienceHandler`. +- Workers swallow + log exceptions in their loops; never crash the host. +- Webhook idempotency (Twitch message-id LRU; YouTube confirming poll). +- Reconciliation timers are the safety net for missed webhooks. +- SSE 15 s heartbeat defeats proxy idle-timeouts. +- Client uses exponential backoff + `visibilitychange`-aware reconnect. +- `prefers-reduced-motion` honored throughout. +- Missing secrets ⇒ degraded but functional state. diff --git a/scripts/cleanup-worktree.ps1 b/scripts/cleanup-worktree.ps1 new file mode 100644 index 000000000..a066f3c19 --- /dev/null +++ b/scripts/cleanup-worktree.ps1 @@ -0,0 +1,53 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Tear down a sibling worktree once its PR has merged. + +.DESCRIPTION + GitHub doesn't fire a local hook on PR-merge, so the cleanest we can + get to "automatic cleanup" is this user-invoked helper. Intended to be + run from the main checkout (D:\GitHub\aspire.dev) once the upstream + PR has been merged and the local branch is no longer needed. + +.PARAMETER Name + Short name of the worktree (the leaf folder name under + D:\GitHub\aspire.dev-worktrees). Example: 'live-status'. + +.EXAMPLE + scripts\cleanup-worktree.ps1 live-status +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Name +) + +$ErrorActionPreference = 'Stop' +$root = (git rev-parse --show-toplevel).Trim() +$worktreePath = Join-Path (Split-Path $root -Parent) "aspire.dev-worktrees\$Name" +$branch = "dapine/$Name" + +if (-not (Test-Path $worktreePath)) +{ + Write-Warning "Worktree path '$worktreePath' does not exist. Continuing with branch + prune only." +} +else +{ + Write-Host "Removing worktree at $worktreePath" -ForegroundColor Cyan + git worktree remove $worktreePath +} + +if (git show-ref --verify --quiet "refs/heads/$branch") +{ + Write-Host "Deleting local branch $branch" -ForegroundColor Cyan + git branch -D $branch +} +else +{ + Write-Host "Local branch $branch already absent." -ForegroundColor DarkGray +} + +Write-Host "Pruning upstream refs and worktree state" -ForegroundColor Cyan +git fetch --prune upstream +git worktree prune + +Write-Host "Done." -ForegroundColor Green diff --git a/scripts/cleanup-worktree.sh b/scripts/cleanup-worktree.sh new file mode 100644 index 000000000..e4fc10611 --- /dev/null +++ b/scripts/cleanup-worktree.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Tear down a sibling worktree once its PR has merged. +# Usage: scripts/cleanup-worktree.sh +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi + +NAME="$1" +ROOT="$(git rev-parse --show-toplevel)" +PARENT="$(dirname "$ROOT")" +WORKTREE_PATH="$PARENT/aspire.dev-worktrees/$NAME" +BRANCH="dapine/$NAME" + +if [[ -d "$WORKTREE_PATH" ]]; then + echo "Removing worktree at $WORKTREE_PATH" + git worktree remove "$WORKTREE_PATH" +else + echo "Worktree path '$WORKTREE_PATH' does not exist; continuing." +fi + +if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + echo "Deleting local branch $BRANCH" + git branch -D "$BRANCH" +fi + +echo "Pruning upstream refs and worktree state" +git fetch --prune upstream +git worktree prune +echo "Done." diff --git a/src/apphost/Aspire.Dev.AppHost/AppHost.cs b/src/apphost/Aspire.Dev.AppHost/AppHost.cs index bc378be07..779d4d13c 100644 --- a/src/apphost/Aspire.Dev.AppHost/AppHost.cs +++ b/src/apphost/Aspire.Dev.AppHost/AppHost.cs @@ -5,6 +5,26 @@ if (builder.ExecutionContext.IsRunMode) { + staticHostWebsite + .WithUrlForEndpoint("http", static url => url.DisplayText = "aspire.dev (StaticHost)") + .WithUrls(ctx => + { + if (ctx.Resource is not Aspire.Hosting.ApplicationModel.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", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/youtube/webhook", DisplayText = "YouTube WebSub webhook", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/scalar/v1", DisplayText = "API reference (Scalar)", Endpoint = endpoint }); + }); + // For local development: Use ViteApp for hot reload and development experience builder.AddViteApp("frontend", "../../frontend") .WithPnpm() diff --git a/src/frontend/src/assets/icons/live.svg b/src/frontend/src/assets/icons/live.svg new file mode 100644 index 000000000..e7769b958 --- /dev/null +++ b/src/frontend/src/assets/icons/live.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/components/LivePip.astro b/src/frontend/src/components/LivePip.astro new file mode 100644 index 000000000..7d39cb0ff --- /dev/null +++ b/src/frontend/src/components/LivePip.astro @@ -0,0 +1,254 @@ +--- +/** + * Floating live-stream "PiP" panel. Rendered once globally from the + * site Header. Hidden until `aspire:live-change` reports `isLive: true`. + * + * This is a custom in-page floating panel rather than the Document + * Picture-in-Picture API: cross-origin iframes (YouTube/Twitch) don't + * survive the document transfer, and the panel needs to be styleable + + * testable. + */ +--- + + + + + + diff --git a/src/frontend/src/components/LiveVideosTabs.astro b/src/frontend/src/components/LiveVideosTabs.astro new file mode 100644 index 000000000..275de0033 --- /dev/null +++ b/src/frontend/src/components/LiveVideosTabs.astro @@ -0,0 +1,150 @@ +--- +import { Tabs, TabItem } from '@astrojs/starlight/components'; +import YouTubeEmbed from '@components/YouTubeEmbed.astro'; +import TwitchEmbed from '@components/TwitchEmbed.astro'; + +export interface Props { + youtubeChannelId: string; + twitchChannel: string; +} + +const { youtubeChannelId, twitchChannel } = Astro.props; +--- + +
+ + +
+ + +
+

When we’re not live, this shows the channel placeholder. Browse all past streams on youtube.com/@aspiredotdev.

+
+ +
+ + +
+

When we’re not live, the Twitch player shows the offline screen. Follow twitch.tv/{twitchChannel} to get notified.

+
+
+
+ + + + diff --git a/src/frontend/src/components/live-status.ts b/src/frontend/src/components/live-status.ts new file mode 100644 index 000000000..6704e453b --- /dev/null +++ b/src/frontend/src/components/live-status.ts @@ -0,0 +1,203 @@ +/** + * Live status client. Singleton; idempotent across Astro view transitions. + * + * Wires the header `.live-btn` icon and dispatches a typed + * `aspire:live-change` CustomEvent on `document` whenever the snapshot + * actually changes. Reconnects on errors with exponential backoff and + * forces a reconnect when the tab becomes visible again. + */ + +export interface LiveSnapshot { + isLive: boolean; + primarySource: 'twitch' | 'youtube' | null; + twitch: { live: boolean; channel: string | null }; + youtube: { live: boolean; videoId: string | null }; + updatedAt: string; +} + +declare global { + interface DocumentEventMap { + 'aspire:live-change': CustomEvent; + } +} + +const EMPTY: LiveSnapshot = { + isLive: false, + primarySource: null, + twitch: { live: false, channel: null }, + youtube: { live: false, videoId: null }, + updatedAt: new Date(0).toISOString(), +}; + +const BACKOFF_MS = [1_000, 2_000, 5_000, 15_000, 30_000]; + +let started = false; +let current: LiveSnapshot = EMPTY; +let source: EventSource | null = null; +let backoffIndex = 0; +let reconnectTimer: ReturnType | null = null; +const listeners = new Set<(s: LiveSnapshot) => void>(); + +function snapshotsEqual(a: LiveSnapshot, b: LiveSnapshot): boolean { + return ( + a.isLive === b.isLive && + a.primarySource === b.primarySource && + a.twitch.live === b.twitch.live && + a.twitch.channel === b.twitch.channel && + a.youtube.live === b.youtube.live && + a.youtube.videoId === b.youtube.videoId + ); +} + +function applySnapshot(next: LiveSnapshot, force = false): void { + if (!force && snapshotsEqual(current, next)) { + current = next; + return; + } + current = next; + syncDom(); + for (const fn of listeners) { + try { + fn(current); + } catch (err) { + console.error('[live-status] listener threw', err); + } + } + document.dispatchEvent( + new CustomEvent('aspire:live-change', { detail: current }), + ); +} + +function syncDom(): void { + const buttons = document.querySelectorAll('.live-btn'); + const sourceAttr = current.isLive + ? current.twitch.live && current.youtube.live + ? 'both' + : (current.primarySource ?? 'none') + : 'none'; + buttons.forEach((btn) => { + btn.dataset.live = current.isLive ? 'true' : 'false'; + btn.dataset.source = sourceAttr; + btn.setAttribute( + 'aria-label', + current.isLive ? 'Aspire is live — watch now' : 'Watch Aspire videos', + ); + }); +} + +async function seed(): Promise { + try { + const res = await fetch('/api/live', { headers: { Accept: 'application/json' } }); + if (res.ok) { + const json = (await res.json()) as LiveSnapshot; + applySnapshot(json, true); + } + } catch { + /* fine — SSE will catch up */ + } +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + const delay = BACKOFF_MS[Math.min(backoffIndex, BACKOFF_MS.length - 1)]; + backoffIndex = Math.min(backoffIndex + 1, BACKOFF_MS.length - 1); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} + +function connect(): void { + closeSource(); + try { + source = new EventSource('/api/live/stream'); + } catch (err) { + console.warn('[live-status] EventSource unavailable', err); + scheduleReconnect(); + return; + } + + source.onopen = () => { + backoffIndex = 0; + }; + + source.addEventListener('state', (evt) => { + try { + const data = JSON.parse((evt as MessageEvent).data) as LiveSnapshot; + applySnapshot(data); + } catch (err) { + console.warn('[live-status] failed to parse state event', err); + } + }); + + source.addEventListener('meta', (evt) => { + try { + const data = JSON.parse((evt as MessageEvent).data) as LiveSnapshot; + // meta updates do not retrigger animations: only update buffered state. + current = data; + } catch { + /* ignore */ + } + }); + + source.onerror = () => { + closeSource(); + scheduleReconnect(); + }; +} + +function closeSource(): void { + if (source) { + try { + source.close(); + } catch { + /* ignore */ + } + source = null; + } +} + +function onVisibilityChange(): void { + if (document.visibilityState === 'visible' && !source) { + backoffIndex = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + connect(); + } +} + +export function getCurrent(): LiveSnapshot { + return current; +} + +export function subscribe(listener: (s: LiveSnapshot) => void): () => void { + listeners.add(listener); + // deliver immediately so late subscribers aren't stale + try { + listener(current); + } catch (err) { + console.error('[live-status] listener threw on subscribe', err); + } + return () => listeners.delete(listener); +} + +export function init(): void { + // Always re-sync the DOM so freshly swapped header buttons get the right state. + syncDom(); + if (started) return; + started = true; + void seed(); + connect(); + document.addEventListener('visibilitychange', onVisibilityChange); +} + +if (typeof window !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } + document.addEventListener('astro:after-swap', init); +} diff --git a/src/frontend/src/components/starlight/Header.astro b/src/frontend/src/components/starlight/Header.astro index 2507e9310..e76e372bf 100644 --- a/src/frontend/src/components/starlight/Header.astro +++ b/src/frontend/src/components/starlight/Header.astro @@ -3,7 +3,9 @@ import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro'; import Search from 'virtual:starlight/components/Search'; import { Icon } from '@astrojs/starlight/components'; import InstallCliModal from '@components/InstallCliModal.astro'; +import LivePip from '@components/LivePip.astro'; import CookiesSvg from '@assets/icons/cookies.svg'; +import LiveSvg from '@assets/icons/live.svg'; import SiteNavHelpSvg from '@assets/icons/site-nav-help.svg'; const isSiteTourEnabled = import.meta.env.PUBLIC_ENABLE_SITE_TOUR === 'true'; @@ -32,6 +34,18 @@ const isSiteTourEnabled = import.meta.env.PUBLIC_ENABLE_SITE_TOUR === 'true'; ) } + + Watch Aspire videos + + +