shivao-projeto/server/src/realtime.js
PontualTech / Karlão 21b91b3522
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
feat(sync): WebSocket realtime + auto-push/pull entre PC e celular v1.5.0
Backend (server/src/realtime.js):
- WebSocket server em /ws via lib `ws`
- Auth por JWT ou BOAT_TOKEN (mesmo middleware do REST)
- Broadcast de notificações state:changed por user (skip device origem)
- Heartbeat ping/pong + cleanup de conexões mortas
- Presença: avisa todos os devices do user quantos estão online
- POST /api/data agora dispara broadcast pra outros devices em tempo real

Frontend (app/diario-bordo.html):
- Cliente WS com reconnect exponencial (1s→2s→5s→15s→30s→60s)
- deviceId persistente em localStorage (gerado no primeiro boot)
- Heartbeat 25s pra manter NAT/proxy abertos
- Auto-push debounced 2.5s no saveState (acumula edições rápidas)
- Auto-pull debounced 300ms no recebimento de state:changed
- Reconnect ao voltar pro foreground + ao recuperar conexão
- Indicador visual no header: 🟢 online · 🟡 syncing · 🔴 offline ·  disabled · ⚠️ erro

Echo prevention em 3 camadas:
1) Server skip por originDeviceId (header X-Device-Id)
2) Cliente ignora notif do próprio device
3) Guard temporal: pull rejeita se updated_at < lastPushAt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 06:51:35 -03:00

126 lines
3.8 KiB
JavaScript

// Realtime sync via WebSocket — broadcast state-change notifications between devices of the same user.
// Cliente reage à notificação fazendo pull do estado via REST. Sem entity-level diffing.
import { WebSocketServer } from 'ws';
import { verifyToken } from './auth.js';
const TOKEN = process.env.BOAT_TOKEN;
const HEARTBEAT_INTERVAL = 30000;
// Map<userId, Set<WebSocket>>
const clientsByUser = new Map();
function addClient(userId, ws) {
if (!clientsByUser.has(userId)) clientsByUser.set(userId, new Set());
clientsByUser.get(userId).add(ws);
}
function removeClient(userId, ws) {
const set = clientsByUser.get(userId);
if (!set) return;
set.delete(ws);
if (set.size === 0) clientsByUser.delete(userId);
}
// Resolve um token (JWT ou BOAT_TOKEN) → userId. Retorna null se inválido.
function authenticateToken(token) {
if (!token) return null;
if (token === TOKEN) return 1; // legacy single-tenant
const payload = verifyToken(token);
if (!payload || payload.type !== 'access' || !payload.uid) return null;
return payload.uid;
}
export function initRealtime(httpServer) {
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const token = url.searchParams.get('token') || '';
const deviceId = url.searchParams.get('device') || ('anon-' + Math.random().toString(36).slice(2, 8));
const userId = authenticateToken(token);
if (!userId) {
ws.send(JSON.stringify({ type: 'error', code: 'auth_failed' }));
ws.close(1008, 'auth failed');
return;
}
ws.userId = userId;
ws.deviceId = deviceId;
ws.isAlive = true;
addClient(userId, ws);
ws.send(JSON.stringify({ type: 'hello', userId, deviceId, ts: Date.now() }));
// Quantos devices estão online pro mesmo user
broadcastPresence(userId);
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (raw) => {
// Cliente pode mandar pings explícitos ou notificações. Ignoramos qualquer outra coisa.
try {
const msg = JSON.parse(raw.toString());
if (msg.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
}
} catch (e) { /* ignore */ }
});
ws.on('close', () => {
removeClient(userId, ws);
broadcastPresence(userId);
});
ws.on('error', (err) => {
console.warn('[ws] error', err.message);
});
});
// Heartbeat: drop dead connections
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
try { ws.ping(); } catch (e) {}
});
}, HEARTBEAT_INTERVAL);
wss.on('close', () => clearInterval(heartbeat));
console.log('[ws] WebSocket server attached at /ws');
return wss;
}
// Notifica todos os devices do user (exceto o que originou) que o estado mudou.
// payload: { kind: 'state'|'pending'|'trip'|..., ts, originDeviceId? }
export function broadcastStateChange(userId, payload = {}) {
const set = clientsByUser.get(userId);
if (!set) return;
const msg = JSON.stringify({
type: 'state:changed',
ts: Date.now(),
...payload,
});
for (const ws of set) {
if (ws.readyState !== ws.OPEN) continue;
if (payload.originDeviceId && ws.deviceId === payload.originDeviceId) continue;
try { ws.send(msg); } catch (e) {}
}
}
function broadcastPresence(userId) {
const set = clientsByUser.get(userId);
if (!set) return;
const msg = JSON.stringify({ type: 'presence', count: set.size, ts: Date.now() });
for (const ws of set) {
if (ws.readyState === ws.OPEN) {
try { ws.send(msg); } catch (e) {}
}
}
}
export function getOnlineCount(userId) {
return clientsByUser.get(userId)?.size || 0;
}