feat(chat): desktop usando Convex WS direto e fallback WS dedicado

This commit is contained in:
esdrasrenan 2025-12-09 01:01:54 -03:00
parent 8db7c3c810
commit a8f5ff9d51
14 changed files with 735 additions and 458 deletions

130
scripts/chat-ws-server.mjs Normal file
View file

@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Servidor WebSocket dedicado para notificações de chat (máquinas).
*
* Por enquanto ele replica a lógica de streaming via SSE/poll:
* - autentica via machineToken (query ?token=)
* - consulta checkMachineUpdates a cada 1s
* - envia eventos "connected", "update" e "heartbeat"
* - fecha em caso de erro de autenticação
*
* Isso permite remover SSE/poll no cliente, mantendo compatibilidade com o
* backend Convex existente.
*/
import { WebSocketServer } from "ws"
import { ConvexHttpClient } from "convex/browser"
import { api } from "../convex/_generated/api.js"
const PORT = Number(process.env.CHAT_WS_PORT ?? process.env.PORT_WS ?? 3030)
const POLL_MS = Number(process.env.CHAT_WS_POLL_MS ?? 1000)
const HEARTBEAT_MS = Number(process.env.CHAT_WS_HEARTBEAT_MS ?? 30000)
const convexUrl =
process.env.CONVEX_INTERNAL_URL ??
process.env.NEXT_PUBLIC_CONVEX_URL ??
process.env.CONVEX_URL ??
null
if (!convexUrl) {
console.error("[chat-ws] ERRO: defina CONVEX_INTERNAL_URL ou NEXT_PUBLIC_CONVEX_URL")
process.exit(1)
}
const wss = new WebSocketServer({ port: PORT })
console.log(`[chat-ws] Servidor WebSocket iniciado na porta ${PORT}`)
function buildClient() {
return new ConvexHttpClient(convexUrl)
}
function parseToken(urlString) {
try {
const url = new URL(urlString, "http://localhost")
return url.searchParams.get("token")
} catch {
return null
}
}
wss.on("connection", (ws, req) => {
const token = parseToken(req.url ?? "")
if (!token) {
ws.close(1008, "Missing token")
return
}
const client = buildClient()
let previousState = null
let closed = false
const send = (event, data) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ event, data }))
}
}
// Heartbeat
const heartbeat = setInterval(() => {
if (closed) return
send("heartbeat", { ts: Date.now() })
}, HEARTBEAT_MS)
// Poll
const poll = setInterval(async () => {
if (closed) return
try {
const result = await client.query(api.liveChat.checkMachineUpdates, {
machineToken: token,
})
const currentState = JSON.stringify({
hasActiveSessions: result.hasActiveSessions,
totalUnread: result.totalUnread,
sessions: result.sessions,
})
if (currentState !== previousState) {
previousState = currentState
send("update", { ...result, ts: Date.now() })
}
} catch (error) {
console.error("[chat-ws] Poll error:", error?.message ?? error)
send("error", { message: "Poll failed" })
ws.close(1011, "Poll failed")
}
}, POLL_MS)
// Primeira validação + evento inicial
client
.query(api.liveChat.checkMachineUpdates, { machineToken: token })
.then((result) => {
previousState = JSON.stringify({
hasActiveSessions: result.hasActiveSessions,
totalUnread: result.totalUnread,
sessions: result.sessions,
})
send("connected", { ts: Date.now(), ...result })
})
.catch((error) => {
console.error("[chat-ws] Token inválido:", error?.message ?? error)
send("error", { message: "Token inválido" })
ws.close(1008, "Invalid token")
})
ws.on("close", () => {
closed = true
clearInterval(poll)
clearInterval(heartbeat)
})
ws.on("error", (err) => {
console.error("[chat-ws] WS erro:", err?.message ?? err)
closed = true
clearInterval(poll)
clearInterval(heartbeat)
})
})
wss.on("error", (err) => {
console.error("[chat-ws] Erro no servidor:", err?.message ?? err)
})

View file

@ -16,6 +16,7 @@ echo "[start-web] Using bun cache dir: $BUN_INSTALL_CACHE_DIR"
echo "[start-web] Using APP_DIR=$(pwd)"
echo "[start-web] NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-}"
echo "[start-web] NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-}"
echo "[start-web] CHAT_WS_PORT=${CHAT_WS_PORT:-3030}"
ensure_db_writable() {
mkdir -p "$(dirname "$DB_PATH")"
@ -203,6 +204,19 @@ else
echo "[start-web] skipping auth seed (SKIP_AUTH_SEED=true)"
fi
# Iniciar servidor WebSocket de chat (processo dedicado)
CHAT_WS_PORT="${CHAT_WS_PORT:-3030}"
CHAT_WS_SCRIPT="/app/scripts/chat-ws-server.mjs"
if [ -f "$CHAT_WS_SCRIPT" ]; then
echo "[start-web] iniciando chat-ws-server em :$CHAT_WS_PORT"
node "$CHAT_WS_SCRIPT" &
CHAT_WS_PID=$!
# Garantir cleanup
trap "kill $CHAT_WS_PID 2>/dev/null || true" EXIT
else
echo "[start-web] chat-ws-server não encontrado em $CHAT_WS_SCRIPT" >&2
fi
echo "[start-web] launching Next.js"
PORT=${PORT:-3000}
NODE_MAJOR=$(command -v node >/dev/null 2>&1 && node -v | sed -E 's/^v([0-9]+).*/\1/' || echo "")