feat: SSE para chat desktop, rate limiting, retry, testes e atualizacao de stack
- Implementa Server-Sent Events (SSE) para chat no desktop com fallback HTTP - Adiciona rate limiting nas APIs de chat (poll, messages, sessions) - Adiciona retry com backoff exponencial para mutations - Cria testes para modulo liveChat (20 testes) - Corrige testes de SMTP (unit tests para extractEnvelopeAddress) - Adiciona indice by_status_lastActivity para cron de sessoes inativas - Atualiza stack: Bun 1.3.4, React 19, recharts 3, noble/hashes 2, etc 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0e0bd9a49c
commit
d01c37522f
19 changed files with 1465 additions and 443 deletions
167
src/app/api/machines/chat/stream/route.ts
Normal file
167
src/app/api/machines/chat/stream/route.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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 2s e push via SSE
|
||||
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()
|
||||
}
|
||||
}, 2_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",
|
||||
},
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue