- 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>
167 lines
5 KiB
TypeScript
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",
|
|
},
|
|
})
|
|
}
|