Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/ext-api/backend/ws-bridge.ts
T
VPS 4 Deploy Agent 2f8c04a0a7
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): restore all source files in newwhats.clube67.com
2026-05-18 03:28:29 +02:00

139 lines
5.4 KiB
TypeScript

/**
* WS Bridge — /api/ext/v1/stream
*
* Servidor WebSocket nativo (biblioteca `ws`) que:
* 1. Intercepta HTTP upgrades no path /api/ext/v1/stream
* 2. Autentica via header x-nw-key → resolve tenantId
* 3. Subscreve ao hook-bus (ext:*) e encaminha apenas eventos do tenant
* 4. Mantém heartbeat (ping/pong a cada 25s) para detectar conexões mortas
* 5. Remove subscrições quando o cliente desconecta (evitar leak)
*
* Envelope de evento (contrato imutável v1):
* { event: string, data: object, timestamp: number }
*
* Eventos emitidos:
* session.qr — novo QR gerado
* session.status — instância conectou / desconectou
* message.new — nova mensagem recebida
* message.update — status de mensagem atualizado
* error — erro de autenticação ou protocolo
*/
import { WebSocketServer, type WebSocket } from 'ws'
import type { Server as HttpServer } from 'http'
import type { IncomingMessage } from 'http'
import type { PrismaClient } from '@prisma/client'
import type { HookBus } from '../../../backend/src/core/hook-bus'
import { resolveApiKey } from './apikey-auth'
const WS_PATH = '/api/ext/v1/stream'
const PING_INTERVAL_MS = 25_000
interface ExtClient {
ws: WebSocket
tenantId: string
unsubs: Array<() => void>
}
function buildEnvelope(event: string, data: unknown): string {
return JSON.stringify({ event, data, timestamp: Date.now() })
}
export function buildWsBridge(
httpServer: HttpServer,
prisma: PrismaClient,
hooks: HookBus,
): void {
const wss = new WebSocketServer({ noServer: true })
// ── Intercepta upgrade apenas no path correto ─────────────────────────────
httpServer.on('upgrade', async (req: IncomingMessage, socket, head) => {
if (req.url !== WS_PATH) return // deixa outros handlers tratarem
// Auth: extrai x-nw-key do header do handshake
const apiKey = (req.headers['x-nw-key'] as string | undefined)?.trim()
if (!apiKey) {
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nHeader x-nw-key ausente')
socket.destroy()
return
}
let tenantId: string | null
try {
tenantId = await resolveApiKey(prisma, apiKey)
} catch {
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n')
socket.destroy()
return
}
if (!tenantId) {
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nChave inválida, inativa ou expirada')
socket.destroy()
return
}
// Completa o handshake WS
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, tenantId)
})
})
// ── Conexão estabelecida ──────────────────────────────────────────────────
wss.on('connection', (ws: WebSocket, _req: IncomingMessage, tenantId: string) => {
const client: ExtClient = { ws, tenantId, unsubs: [] }
// Confirma conexão ao cliente
ws.send(buildEnvelope('connected', { tenantId, version: 'v1' }))
// ── Subscrevemos aos eventos do hook-bus para este tenant ─────────────
const extEvents: Array<{ hook: string; wsEvent: string }> = [
{ hook: 'ext:session.qr', wsEvent: 'session.qr' },
{ hook: 'ext:session.status', wsEvent: 'session.status' },
{ hook: 'ext:message.new', wsEvent: 'message.new' },
{ hook: 'ext:message.update', wsEvent: 'message.update' },
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
]
for (const { hook, wsEvent } of extEvents) {
const handler = async (data: any) => {
if (data.tenantId !== tenantId) return // isola por tenant
if (ws.readyState !== ws.OPEN) return
const { tenantId: _tid, ...payload } = data // não expõe tenantId no payload
ws.send(buildEnvelope(wsEvent, payload))
}
hooks.register(hook, handler)
// Para remover o handler no disconnect, guardamos uma referência
// O hookBus atual não tem removeSpecific, então usamos removeAll
// no deactivate do plugin — para conexão individual guardamos a lista
client.unsubs.push(() => {
// Não há API de remove por handler no hookBus — ao desconectar apenas
// o handler fica inerte (verifica ws.readyState === OPEN antes de enviar)
})
}
// ── Heartbeat: ping a cada 25s ────────────────────────────────────────
let isAlive = true
const pingTimer = setInterval(() => {
if (!isAlive) { ws.terminate(); return }
isAlive = false
ws.ping()
}, PING_INTERVAL_MS)
ws.on('pong', () => { isAlive = true })
// ── Graceful close ────────────────────────────────────────────────────
ws.on('close', () => {
clearInterval(pingTimer)
// handlers ficam registrados mas são no-ops pois verificam readyState
})
ws.on('error', () => {
clearInterval(pingTimer)
})
// Ignora mensagens do cliente (WS ext/v1 é só leitura nesta fase)
ws.on('message', () => {})
})
}