Corrige lentidão do frontend com múltiplos clientes simultâneos

---

- Substituída consulta individual por stream (supervisorctl status {p}-{id} × 5) por uma única chamada supervisorctl status que retorna todos os programas de uma vez, com cache de 3s em fetchAllStatuses();
- Adicionado endpoint GET /api/streams/statuses que retorna os statuses de todos os streams em uma resposta batch;
- Frontend alterado para usar o endpoint batch em vez de N requests paralelos por ciclo de polling;
- Token de sessão HMAC-SHA256 em auth.ts agora é computado uma vez e cacheado no módulo, eliminando operações de crypto a cada requisição;
- Rolling session no middleware limitado a rotas de página, removendo overhead de Set-Cookie em respostas de API e HLS;

---
This commit is contained in:
2026-04-27 10:41:23 -03:00
parent 8046310f27
commit 059807b9ef
5 changed files with 65 additions and 33 deletions
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server"
import { getAllStreamStatuses } from "@/lib/supervisor"
export async function GET() {
return NextResponse.json(getAllStreamStatuses())
}
+3 -8
View File
@@ -135,14 +135,9 @@ export default function GalleryPage() {
const fetchStatuses = useCallback(async (list: Stream[]) => {
if (list.length === 0) return
const results = await Promise.all(
list.map(async (s) => {
const res = await fetch(`/api/streams/${s.id}/status`)
const data = await res.json()
return [s.id, data] as const
})
)
setStatuses(Object.fromEntries(results))
const res = await fetch("/api/streams/statuses")
const data: Record<string, Record<string, string>> = await res.json()
setStatuses(data)
}, [])
useEffect(() => {
+10 -8
View File
@@ -3,17 +3,19 @@
export const AUTH_ENABLED = !!(process.env.AUTH_USER && process.env.AUTH_PASS)
export const COOKIE_NAME = "ds_session"
// Cached promise — token is deterministic (env vars never change at runtime)
let _tokenCache: Promise<string> | null = null
// HMAC-SHA256(user, key=pass) — deterministic, no in-memory state, survives restarts
// Works in both Edge (SubtleCrypto) and Node.js runtime
export async function computeSessionToken(): Promise<string> {
export function computeSessionToken(): Promise<string> {
if (_tokenCache) return _tokenCache
const user = process.env.AUTH_USER ?? ""
const pass = process.env.AUTH_PASS ?? ""
const enc = new TextEncoder()
const key = await globalThis.crypto.subtle.importKey(
"raw", enc.encode(pass),
{ name: "HMAC", hash: "SHA-256" },
false, ["sign"]
)
const sig = await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(user))
return Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, "0")).join("")
_tokenCache = globalThis.crypto.subtle
.importKey("raw", enc.encode(pass), { name: "HMAC", hash: "SHA-256" }, false, ["sign"])
.then(key => globalThis.crypto.subtle.sign("HMAC", key, enc.encode(user)))
.then(sig => Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, "0")).join(""))
return _tokenCache
}
+38 -12
View File
@@ -132,25 +132,51 @@ export function captureThumb(streamId: string, delay = 60): void {
export type ProgramStatus = "RUNNING" | "STOPPED" | "FATAL" | "STARTING" | "UNKNOWN"
export function getStreamStatus(id: string): Record<string, ProgramStatus> {
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
// Cache for supervisorctl status — refreshed at most once every 3 seconds across all callers
let _statusCache: Record<string, Record<string, ProgramStatus>> | null = null
let _statusCacheAt = 0
const STATUS_CACHE_TTL = 3000
if (IS_DEV) {
return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))
}
function fetchAllStatuses(): Record<string, Record<string, ProgramStatus>> {
const now = Date.now()
if (_statusCache && now - _statusCacheAt < STATUS_CACHE_TTL) return _statusCache
const result: Record<string, ProgramStatus> = {}
for (const p of programs) {
const result: Record<string, Record<string, ProgramStatus>> = {}
try {
// One call for all programs — avoid N×5 blocking execSync calls per poll cycle
const out = execSync(
`supervisorctl -c /etc/supervisor/supervisord.conf status ${p}-${id}`,
`supervisorctl -c /etc/supervisor/supervisord.conf status`,
{ stdio: "pipe" }
).toString()
const match = out.match(/\b(RUNNING|STOPPED|FATAL|STARTING)\b/)
result[p] = (match?.[1] as ProgramStatus) ?? "UNKNOWN"
for (const line of out.split("\n")) {
// e.g. "ffmpeg-abc123 RUNNING pid 42, uptime 0:01:00"
const m = line.match(/^(\S+)-(\S+)\s+(RUNNING|STOPPED|FATAL|STARTING)/)
if (!m) continue
const [, program, id, status] = m
if (!result[id]) result[id] = {}
result[id][program] = status as ProgramStatus
}
} catch {
result[p] = "UNKNOWN"
}
// supervisorctl can exit non-zero; return whatever was parsed
}
_statusCache = result
_statusCacheAt = now
return result
}
export function getStreamStatus(id: string): Record<string, ProgramStatus> {
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
if (IS_DEV) return Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))
const all = fetchAllStatuses()
return all[id] ?? Object.fromEntries(programs.map((p) => [p, "UNKNOWN" as ProgramStatus]))
}
export function getAllStreamStatuses(): Record<string, Record<string, ProgramStatus>> {
const programs = ["xvfb", "chromium", "autologin", "x11vnc", "ffmpeg"]
if (IS_DEV) {
const { readStreams } = require("./db") as typeof import("./db")
return Object.fromEntries(readStreams().map((s) => [s.id, Object.fromEntries(programs.map((p) => [p, "STOPPED" as ProgramStatus]))]))
}
return fetchAllStatuses()
}
+4 -1
View File
@@ -22,7 +22,10 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(url)
}
// Rolling session — refresh cookie on every request, resetting the 30-day timer
// Rolling session — refresh cookie only on page navigations, not API/HLS/asset requests
if (pathname.startsWith("/api/") || pathname.startsWith("/player")) {
return NextResponse.next()
}
const res = NextResponse.next()
res.cookies.set(COOKIE_NAME, cookie, {
httpOnly: true,