feat(chat): desktop usando Convex WS direto e fallback WS dedicado
This commit is contained in:
parent
8db7c3c810
commit
a8f5ff9d51
14 changed files with 735 additions and 458 deletions
130
scripts/chat-ws-server.mjs
Normal file
130
scripts/chat-ws-server.mjs
Normal 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)
|
||||
})
|
||||
|
|
@ -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 "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue