63f2c588da
Two things: 1. FIX a live regression the async conversion missed: chat.js calls repos via the lazy repos() helper (not the R. prefix), so my sweep skipped it — effectiveStatus / broadcastPresence read `repos().users.byId(userId)` synchronously, but that's a Promise now, so presence broadcasts always reported status 'active' and dropped last_seen. Now awaited (effectiveStatus/broadcastPresence async); touchSeen is a fire-and-forget UPDATE with .catch. Audited all non-R. repo calls — only chat.js was affected (media.js backfill was already awaited). 2. Swappable pub/sub for multi-instance real-time fan-out (the actual blocker to running >1 instance — not the DB). server/pubsub.js picks a backend by PUBSUB_BACKEND (default 'memory'). Local socket delivery is UNCHANGED; publish is additive — memory = no-op (zero hot-path cost, identical single-instance behaviour), redis = fan-out to other instances with a self-echo guard. chat.js pushToUser/broadcastPresence now also publish; each instance subscribes to deliver remote events to its local sockets. Interface is tiny so Redis is one swappable file (Postgres LISTEN/NOTIFY or NATS could drop in the same way — never hardwired, as requested). Dormant redis service added to compose behind the 'scale' profile; redis dep added; PUBSUB_BACKEND/REDIS_URL documented. Validated: smoke 22/22 (memory), e2e chat delivery green. NOTE: full multi-instance also needs distributed presence (isOnline is per-process) + meeting-signaling sharing — chat/presence fan out via this layer; those are follow-ups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
86 lines
4.4 KiB
JavaScript
86 lines
4.4 KiB
JavaScript
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends `chat-hello`;
|
|
// signaling.js registers the socket here. Messages are persisted over HTTP (routes.js) and pushed live to
|
|
// the recipient's sockets via pushToUser().
|
|
//
|
|
// MULTI-INSTANCE: local delivery (to sockets on THIS process) is unchanged. Every push is ALSO published
|
|
// via the swappable pubsub layer so, when >1 instance runs, a recipient connected to another instance
|
|
// still gets it. With the default in-memory pubsub (single instance) publish is a no-op, so this is
|
|
// behaviourally identical to before — no hot-path cost.
|
|
const { chatClients, meetingRooms } = require('./presence');
|
|
const pubsub = require('./pubsub');
|
|
let _repos = null; const repos = () => (_repos || (_repos = require('./repos'))); // lazy: avoid a require cycle
|
|
|
|
// Deliver an already-built or plain object to a user's sockets on THIS instance.
|
|
function deliverLocal(userId, obj) {
|
|
const s = chatClients.get(userId);
|
|
if (!s) return;
|
|
const data = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
|
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
|
|
}
|
|
|
|
function register(userId, ws) {
|
|
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
|
|
chatClients.get(userId).add(ws);
|
|
ws._chatUserId = userId;
|
|
repos().users.touchSeen(userId).catch(() => {}); // "last seen" (#2) — fire-and-forget UPDATE
|
|
}
|
|
|
|
function unregister(ws) {
|
|
const id = ws && ws._chatUserId;
|
|
if (!id) return;
|
|
const set = chatClients.get(id);
|
|
if (set) {
|
|
set.delete(ws);
|
|
// Their LAST socket went away → stamp when they were last here, so contacts can show "last seen …".
|
|
if (!set.size) { chatClients.delete(id); repos().users.touchSeen(id).catch(() => {}); }
|
|
}
|
|
}
|
|
|
|
function isOnline(userId) {
|
|
const s = chatClients.get(userId);
|
|
return !!(s && s.size);
|
|
}
|
|
|
|
function pushToUser(userId, obj) {
|
|
deliverLocal(userId, obj); // sockets on this instance
|
|
pubsub.publish('u:' + userId, obj); // other instances (no-op on the memory backend)
|
|
}
|
|
|
|
// --- Live presence -------------------------------------------------------------------------
|
|
// A user's effective status (online / in-a-call / away / …) changes with no HTTP round-trip: they connect
|
|
// or disconnect a socket, or join/leave a call. Without pushing that change, OTHER users only see it after
|
|
// a full page reload — impossible in the desktop/mobile apps. So whenever it changes we broadcast the
|
|
// user's fresh status to everyone else's sockets, and the client updates that contact's dot/subtitle.
|
|
function isInCall(userId) {
|
|
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId === userId) return true; } }
|
|
return false;
|
|
}
|
|
async function effectiveStatus(userId) {
|
|
if (isInCall(userId)) return 'incall'; // derived (overrides the stored status)
|
|
try { const u = await repos().users.byId(userId); return (u && u.status) || 'active'; } catch (_) { return 'active'; }
|
|
}
|
|
// Send a presence payload to every local socket EXCEPT the subject's own.
|
|
function deliverPresenceLocal(subjectId, payload) {
|
|
for (const [uid, set] of chatClients) {
|
|
if (uid === subjectId) continue;
|
|
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
|
|
}
|
|
}
|
|
async function broadcastPresence(userId) {
|
|
if (!userId) return;
|
|
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
|
|
// bare "Offline" until the next sidebar reload (#2).
|
|
let lastSeen = null;
|
|
try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
|
|
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: await effectiveStatus(userId), lastSeen });
|
|
deliverPresenceLocal(userId, payload); // this instance
|
|
pubsub.publish('presence', { userId, payload }); // other instances (no-op on memory)
|
|
}
|
|
|
|
// Cross-instance inbound: deliver events published by OTHER instances to our local sockets. On the memory
|
|
// backend these never fire; on redis they carry the fan-out. (Buffered until pubsub.init() connects.)
|
|
pubsub.subscribe('u:*', (channel, obj) => deliverLocal(channel.slice(2), obj));
|
|
pubsub.subscribe('presence', (channel, msg) => { if (msg && msg.payload) deliverPresenceLocal(msg.userId, msg.payload); });
|
|
|
|
module.exports = { register, unregister, isOnline, pushToUser, broadcastPresence };
|