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
|
|
@ -36,7 +36,18 @@ export function createCorsPreflight(origin: string | null, methods = "POST, OPTI
|
|||
return applyCorsHeaders(response, origin, methods)
|
||||
}
|
||||
|
||||
export function jsonWithCors<T>(data: T, init: number | ResponseInit, origin: string | null, methods = "POST, OPTIONS") {
|
||||
export function jsonWithCors<T>(
|
||||
data: T,
|
||||
init: number | ResponseInit,
|
||||
origin: string | null,
|
||||
methods = "POST, OPTIONS",
|
||||
extraHeaders?: Record<string, string>
|
||||
) {
|
||||
const response = NextResponse.json(data, typeof init === "number" ? { status: init } : init)
|
||||
if (extraHeaders) {
|
||||
for (const [key, value] of Object.entries(extraHeaders)) {
|
||||
response.headers.set(key, value)
|
||||
}
|
||||
}
|
||||
return applyCorsHeaders(response, origin, methods)
|
||||
}
|
||||
|
|
|
|||
105
src/server/rate-limit.ts
Normal file
105
src/server/rate-limit.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Rate Limiting simples em memoria para APIs de maquina.
|
||||
* Adequado para VPS single-node. Para escalar horizontalmente,
|
||||
* considerar usar Redis ou outro store distribuido.
|
||||
*/
|
||||
|
||||
type RateLimitEntry = {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
// Store em memoria - limpo automaticamente
|
||||
const store = new Map<string, RateLimitEntry>()
|
||||
|
||||
export type RateLimitResult = {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
resetAt: number
|
||||
retryAfterMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se uma requisicao deve ser permitida baseado no rate limit.
|
||||
*
|
||||
* @param key - Identificador unico (ex: `chat-poll:${token}`)
|
||||
* @param maxRequests - Numero maximo de requisicoes permitidas na janela
|
||||
* @param windowMs - Tamanho da janela em milissegundos
|
||||
* @returns Resultado com status e informacoes de limite
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
maxRequests: number,
|
||||
windowMs: number
|
||||
): RateLimitResult {
|
||||
const now = Date.now()
|
||||
const entry = store.get(key)
|
||||
|
||||
// Se nao existe entrada ou expirou, criar nova
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
const resetAt = now + windowMs
|
||||
store.set(key, { count: 1, resetAt })
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: maxRequests - 1,
|
||||
resetAt,
|
||||
retryAfterMs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Se atingiu o limite
|
||||
if (entry.count >= maxRequests) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: entry.resetAt,
|
||||
retryAfterMs: entry.resetAt - now,
|
||||
}
|
||||
}
|
||||
|
||||
// Incrementar contador
|
||||
entry.count++
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: maxRequests - entry.count,
|
||||
resetAt: entry.resetAt,
|
||||
retryAfterMs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limites pre-definidos para APIs de maquina
|
||||
*/
|
||||
export const RATE_LIMITS = {
|
||||
// Polling: 60 req/min (permite polling a cada 1s)
|
||||
CHAT_POLL: { maxRequests: 60, windowMs: 60_000 },
|
||||
// Mensagens: 30 req/min
|
||||
CHAT_MESSAGES: { maxRequests: 30, windowMs: 60_000 },
|
||||
// Sessoes: 30 req/min
|
||||
CHAT_SESSIONS: { maxRequests: 30, windowMs: 60_000 },
|
||||
// Upload: 10 req/min
|
||||
CHAT_UPLOAD: { maxRequests: 10, windowMs: 60_000 },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Gera headers de rate limit para a resposta HTTP
|
||||
*/
|
||||
export function rateLimitHeaders(result: RateLimitResult): Record<string, string> {
|
||||
return {
|
||||
"X-RateLimit-Remaining": String(result.remaining),
|
||||
"X-RateLimit-Reset": String(Math.ceil(result.resetAt / 1000)),
|
||||
...(result.allowed ? {} : { "Retry-After": String(Math.ceil(result.retryAfterMs / 1000)) }),
|
||||
}
|
||||
}
|
||||
|
||||
// Limpar entradas expiradas a cada 60 segundos
|
||||
if (typeof setInterval !== "undefined") {
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt <= now) {
|
||||
store.delete(key)
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
}
|
||||
84
src/server/retry.ts
Normal file
84
src/server/retry.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Retry com backoff exponencial para operacoes transientes.
|
||||
* Util para mutations do Convex que podem falhar temporariamente.
|
||||
*/
|
||||
|
||||
export type RetryOptions = {
|
||||
/** Numero maximo de tentativas (default: 3) */
|
||||
maxRetries?: number
|
||||
/** Delay base em ms (default: 100) */
|
||||
baseDelayMs?: number
|
||||
/** Delay maximo em ms (default: 2000) */
|
||||
maxDelayMs?: number
|
||||
/** Funcao para determinar se erro e retryable (default: true para todos exceto validacao) */
|
||||
isRetryable?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<RetryOptions> = {
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 100,
|
||||
maxDelayMs: 2000,
|
||||
isRetryable: (error: unknown) => {
|
||||
// Nao retry em erros de validacao
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase()
|
||||
if (
|
||||
msg.includes("invalido") ||
|
||||
msg.includes("invalid") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("unauthorized") ||
|
||||
msg.includes("forbidden")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa uma funcao com retry e backoff exponencial.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = await withRetry(
|
||||
* () => client.mutation(api.liveChat.postMachineMessage, { ... }),
|
||||
* { maxRetries: 3, baseDelayMs: 100 }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options }
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
// Nao retry se erro nao for retryable
|
||||
if (!opts.isRetryable(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Ultima tentativa - nao esperar, apenas lancar
|
||||
if (attempt >= opts.maxRetries) {
|
||||
break
|
||||
}
|
||||
|
||||
// Calcular delay com backoff exponencial + jitter
|
||||
const exponentialDelay = opts.baseDelayMs * Math.pow(2, attempt)
|
||||
const jitter = Math.random() * opts.baseDelayMs
|
||||
const delay = Math.min(exponentialDelay + jitter, opts.maxDelayMs)
|
||||
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue