Fifth item of the v1.0.6 backlog. "Go Live" was silent when the
nginx-rtmp profile wasn't up — an artist could copy the RTMP URL +
stream key, fire up OBS, hit "Start Streaming" and broadcast into the
void with no in-UI signal that the ingest wasn't listening. The audit
flagged this 🟡 ("livestream sans feedback UI si nginx-rtmp down").
Backend (`GET /api/v1/live/health`)
* `LiveHealthHandler` TCP-dials `NGINX_RTMP_ADDR` (default
`localhost:1935`) with a 2s timeout. Reports `rtmp_reachable`,
`rtmp_addr`, a UI-safe `error` string (no raw dial target in the
body — avoids leaking internal hostnames to the browser), and
`last_check_at`.
* 15s TTL cache protected by a mutex so a burst of page loads can't
hammer the ingest. First call dials; subsequent calls within TTL
serve the cached verdict.
* Response ships `Cache-Control: private, max-age=15` so browsers
piggy-back the same quarter-minute window.
* When the dial fails the handler emits a WARN log so an operator
watching backend logs sees the outage before a user does.
* Public endpoint — no auth. The "RTMP is up / down" signal has no
sensitive payload and is useful pre-login too.
Frontend
* `useLiveHealth()` hook: react-query with 15s stale time, 1 retry,
then falls back to an optimistic `{ rtmpReachable: true }` — we'd
rather miss a banner than flash a false negative during a transient
blip on the health endpoint itself.
* `LiveRtmpHealthBanner`: amber, non-blocking banner with a Retry
button that invalidates the health query. Copy explicitly tells the
artist their stream key is still valid but broadcasting now won't
reach anyone.
* `GoLivePage` wraps `GoLiveView` in a vertical stack with the banner
above — the view itself stays unchanged (the key + instructions
remain readable even when the ingest is down).
Tests
* 3 Go tests: live listener reports reachable + Cache-Control header;
dead address reports unreachable + UI-safe error (asserts no
`127.0.0.1` leak); TTL cache survives listener teardown within
window.
* 3 Vitest tests: banner renders nothing when reachable; banner
visible + Retry enabled when unreachable; Retry invalidates the
right query key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
63 lines
1.9 KiB
TypeScript
63 lines
1.9 KiB
TypeScript
/**
|
|
* useLiveHealth — v1.0.6
|
|
*
|
|
* Polls GET /api/v1/live/health to detect whether the nginx-rtmp ingest is
|
|
* reachable. Surfaces the state to the Go Live UI so artists see a banner
|
|
* when their stream key would go into the void.
|
|
*
|
|
* The backend caches its own TCP dial to 15s; the hook uses the same TTL
|
|
* on react-query so the network sees at most one health call per quarter-
|
|
* minute per tab. Failing requests degrade gracefully to
|
|
* `{ rtmpReachable: true }` — better to miss a banner than to flash a
|
|
* false negative during a transient blip on the health endpoint itself.
|
|
*/
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { apiClient } from '@/services/api/client';
|
|
|
|
export interface LiveHealth {
|
|
rtmpReachable: boolean;
|
|
rtmpAddr: string;
|
|
error: string | null;
|
|
lastCheckAt: string;
|
|
}
|
|
|
|
interface ServerLiveHealth {
|
|
rtmp_reachable?: boolean;
|
|
rtmp_addr?: string;
|
|
error?: string;
|
|
last_check_at?: string;
|
|
}
|
|
|
|
const OPTIMISTIC_FALLBACK: LiveHealth = {
|
|
rtmpReachable: true,
|
|
rtmpAddr: '',
|
|
error: null,
|
|
lastCheckAt: new Date().toISOString(),
|
|
};
|
|
|
|
export const LIVE_HEALTH_QUERY_KEY = ['live', 'health'] as const;
|
|
|
|
export function useLiveHealth(): LiveHealth {
|
|
const { data } = useQuery({
|
|
queryKey: LIVE_HEALTH_QUERY_KEY,
|
|
queryFn: async ({ signal }) => {
|
|
const response = await apiClient.get<ServerLiveHealth>('/live/health', { signal });
|
|
const body = response.data as ServerLiveHealth;
|
|
return {
|
|
rtmpReachable: body?.rtmp_reachable !== false, // default true if the key is missing
|
|
rtmpAddr: body?.rtmp_addr ?? '',
|
|
error: body?.error ?? null,
|
|
lastCheckAt: body?.last_check_at ?? new Date().toISOString(),
|
|
} satisfies LiveHealth;
|
|
},
|
|
staleTime: 15 * 1000,
|
|
gcTime: 60 * 1000,
|
|
retry: 1,
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
|
|
return data ?? OPTIMISTIC_FALLBACK;
|
|
}
|
|
|
|
export { OPTIMISTIC_FALLBACK };
|