sistema-de-chamados/src/app/api/machines/chat/stream/route.ts
esdrasrenan 508f915cf9 fix: corrigir memory leaks e testes de mocks
- Fechar ConvexClient antigo antes de criar novo (evita memory leak)
- Adicionar flag disposed para prevenir race condition em useEffect
- Reduzir polling SSE de 1s para 5s (balanco entre responsividade e carga)
- Adicionar .take() aos mocks de testes para compatibilidade

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 21:49:04 -03:00

167 lines
5 KiB
TypeScript

import { api } from "@/convex/_generated/api"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
import { resolveCorsOrigin } from "@/server/cors"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
// GET /api/machines/chat/stream?token=xxx
// Server-Sent Events endpoint para atualizacoes de chat em tempo real
export async function GET(request: Request) {
const origin = request.headers.get("origin")
const resolvedOrigin = resolveCorsOrigin(origin)
// Extrair token da query string
const url = new URL(request.url)
const token = url.searchParams.get("token")
if (!token) {
return new Response("Missing token", {
status: 400,
headers: {
"Access-Control-Allow-Origin": resolvedOrigin,
"Access-Control-Allow-Credentials": resolvedOrigin !== "*" ? "true" : "false",
},
})
}
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return new Response(error.message, {
status: 500,
headers: {
"Access-Control-Allow-Origin": resolvedOrigin,
"Access-Control-Allow-Credentials": resolvedOrigin !== "*" ? "true" : "false",
},
})
}
throw error
}
// Validar token antes de iniciar stream
try {
await client.query(api.liveChat.checkMachineUpdates, { machineToken: token })
} catch (error) {
const message = error instanceof Error ? error.message : "Token invalido"
return new Response(message, {
status: 401,
headers: {
"Access-Control-Allow-Origin": resolvedOrigin,
"Access-Control-Allow-Credentials": resolvedOrigin !== "*" ? "true" : "false",
},
})
}
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
let isAborted = false
let previousState: string | null = null
const sendEvent = (event: string, data: unknown) => {
if (isAborted) return
try {
controller.enqueue(encoder.encode(`event: ${event}\n`))
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
} catch {
// Stream fechado
isAborted = true
}
}
// Heartbeat a cada 30s para manter conexao viva
const heartbeatInterval = setInterval(() => {
if (isAborted) {
clearInterval(heartbeatInterval)
return
}
sendEvent("heartbeat", { ts: Date.now() })
}, 30_000)
// Poll interno a cada 5s e push via SSE (balanco entre responsividade e carga)
const pollInterval = setInterval(async () => {
if (isAborted) {
clearInterval(pollInterval)
return
}
try {
const result = await client.query(api.liveChat.checkMachineUpdates, {
machineToken: token,
})
// Criar hash do estado para detectar mudancas
const currentState = JSON.stringify({
hasActiveSessions: result.hasActiveSessions,
totalUnread: result.totalUnread,
sessions: result.sessions,
})
// Enviar update apenas se houver mudancas
if (currentState !== previousState) {
sendEvent("update", {
...result,
ts: Date.now(),
})
previousState = currentState
}
} catch (error) {
console.error("[SSE] Poll error:", error)
// Enviar erro e fechar conexao
sendEvent("error", { message: "Poll failed" })
isAborted = true
clearInterval(pollInterval)
clearInterval(heartbeatInterval)
controller.close()
}
}, 5_000)
// Enviar evento inicial de conexao
sendEvent("connected", { ts: Date.now() })
// Cleanup quando conexao for abortada
request.signal.addEventListener("abort", () => {
isAborted = true
clearInterval(heartbeatInterval)
clearInterval(pollInterval)
try {
controller.close()
} catch {
// Ja fechado
}
})
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // Desabilita buffering no nginx
"Access-Control-Allow-Origin": resolvedOrigin,
"Access-Control-Allow-Credentials": resolvedOrigin !== "*" ? "true" : "false",
},
})
}
// OPTIONS para CORS preflight
export async function OPTIONS(request: Request) {
const origin = request.headers.get("origin")
const resolvedOrigin = resolveCorsOrigin(origin)
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": resolvedOrigin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": resolvedOrigin !== "*" ? "true" : "false",
"Access-Control-Max-Age": "86400",
},
})
}