feat: Upgrade Global Presence to SSE architecture

- matrix-presence-aggregator v2 with SSE endpoint
- Created @presence_daemon Matrix user
- SSE proxy in Next.js /api/presence/stream
- Updated frontend to use SSE instead of WebSocket
- Real-time city online count and room presence
This commit is contained in:
Apple
2025-11-26 14:43:46 -08:00
parent c456727d53
commit 5bed515852
18 changed files with 709 additions and 729 deletions

View File

@@ -0,0 +1,64 @@
import { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const PRESENCE_AGGREGATOR_URL = process.env.PRESENCE_AGGREGATOR_URL || "http://localhost:8085/presence/stream";
export async function GET(req: NextRequest) {
try {
const upstream = await fetch(PRESENCE_AGGREGATOR_URL, {
headers: {
accept: "text/event-stream",
},
});
if (!upstream.ok) {
return new Response(
JSON.stringify({ error: "Failed to connect to presence aggregator" }),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
}
const readable = new ReadableStream({
start(controller) {
const reader = upstream.body!.getReader();
function push() {
reader.read().then(({ done, value }) => {
if (done) {
controller.close();
return;
}
controller.enqueue(value);
push();
}).catch((err) => {
console.error("SSE proxy error:", err);
controller.close();
});
}
push();
},
cancel() {
upstream.body?.cancel();
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
});
} catch (error) {
console.error("SSE proxy connection error:", error);
return new Response(
JSON.stringify({ error: "Presence aggregator unavailable" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
}

View File

@@ -10,7 +10,7 @@ import { useGlobalPresence } from '@/hooks/useGlobalPresence'
export default function CityPage() {
const [rooms, setRooms] = useState<CityRoom[]>([])
const [loading, setLoading] = useState(true)
const presence = useGlobalPresence()
const { cityOnline, roomsPresence } = useGlobalPresence()
useEffect(() => {
async function fetchRooms() {
@@ -26,11 +26,12 @@ export default function CityPage() {
fetchRooms()
}, [])
// Calculate total online from presence or fallback to API data
const totalOnline = Object.values(presence).reduce((sum, p) => sum + p.online_count, 0) ||
rooms.reduce((sum, r) => sum + r.members_online, 0)
// Use SSE presence data if available, otherwise fallback to API data
const totalOnline = cityOnline > 0
? cityOnline
: rooms.reduce((sum, r) => sum + r.members_online, 0)
const activeRooms = Object.values(presence).filter(p => p.online_count > 0).length ||
const activeRooms = Object.values(roomsPresence).filter(p => p.online > 0).length ||
rooms.filter(r => r.members_online > 0).length
if (loading) {
@@ -84,7 +85,7 @@ export default function CityPage() {
<RoomCard
key={room.id}
room={room}
livePresence={presence[room.slug]}
livePresence={roomsPresence[room.id]}
/>
))}
</div>
@@ -124,13 +125,13 @@ export default function CityPage() {
interface RoomCardProps {
room: CityRoom
livePresence?: { online_count: number; typing_count: number }
livePresence?: { online: number; typing: number }
}
function RoomCard({ room, livePresence }: RoomCardProps) {
// Use live presence if available, otherwise fallback to API data
const onlineCount = livePresence?.online_count ?? room.members_online
const typingCount = livePresence?.typing_count ?? 0
const onlineCount = livePresence?.online ?? room.members_online
const typingCount = livePresence?.typing ?? 0
const isActive = onlineCount > 0
return (

View File

@@ -4,27 +4,29 @@ import { useState, useEffect } from 'react'
import { globalPresenceClient, RoomPresence } from '@/lib/global-presence'
/**
* Hook for subscribing to global room presence updates
* Hook for subscribing to global room presence updates via SSE
*/
export function useGlobalPresence(): Record<string, RoomPresence> {
const [presence, setPresence] = useState<Record<string, RoomPresence>>({})
export function useGlobalPresence() {
const [cityOnline, setCityOnline] = useState(0)
const [roomsPresence, setRoomsPresence] = useState<Record<string, RoomPresence>>({})
useEffect(() => {
const unsubscribe = globalPresenceClient.subscribe((newPresence) => {
setPresence(newPresence)
const unsubscribe = globalPresenceClient.subscribe((newCityOnline, newRoomsPresence) => {
setCityOnline(newCityOnline)
setRoomsPresence(newRoomsPresence)
})
return unsubscribe
}, [])
return presence
return { cityOnline, roomsPresence }
}
/**
* Hook for getting presence of a specific room
* Hook for getting presence of a specific room by ID
*/
export function useRoomPresence(slug: string): RoomPresence | null {
const allPresence = useGlobalPresence()
return allPresence[slug] || null
export function useRoomPresence(roomId: string): RoomPresence | null {
const { roomsPresence } = useGlobalPresence()
return roomsPresence[roomId] || null
}

View File

@@ -1,84 +1,96 @@
/**
* Global Presence WebSocket Client
* Global Presence SSE Client
*
* Connects to /ws/city/global-presence for real-time room presence updates
* Connects to /api/presence/stream for real-time room presence updates via SSE
*/
export interface RoomPresence {
room_slug: string;
online_count: number;
typing_count: number;
room_id: string;
matrix_room_id?: string;
online: number;
typing: number;
}
export type PresenceCallback = (presence: Record<string, RoomPresence>) => void;
export interface CityPresence {
online_total: number;
rooms_online: number;
}
export interface PresenceEvent {
type: "presence_update";
timestamp: string;
city: CityPresence;
rooms: RoomPresence[];
}
export type PresenceCallback = (
cityOnline: number,
roomsPresence: Record<string, RoomPresence>
) => void;
class GlobalPresenceClient {
private ws: WebSocket | null = null;
private presence: Record<string, RoomPresence> = {};
private eventSource: EventSource | null = null;
private cityOnline: number = 0;
private roomsPresence: Record<string, RoomPresence> = {};
private listeners: Set<PresenceCallback> = new Set();
private reconnectTimeout: NodeJS.Timeout | null = null;
private pingInterval: NodeJS.Timeout | null = null;
private isConnecting = false;
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
if (this.eventSource || this.isConnecting) {
return;
}
if (typeof window === 'undefined') {
return; // SSR - don't connect
}
this.isConnecting = true;
// Determine WebSocket URL
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:7001';
const wsUrl = `${protocol}//${host}/ws/city/global-presence`;
console.log('[GlobalPresence] Connecting to', wsUrl);
const sseUrl = "/api/presence/stream";
console.log('[GlobalPresence] Connecting to SSE:', sseUrl);
try {
this.ws = new WebSocket(wsUrl);
this.eventSource = new EventSource(sseUrl);
this.ws.onopen = () => {
console.log('[GlobalPresence] Connected');
this.eventSource.onopen = () => {
console.log('[GlobalPresence] SSE Connected');
this.isConnecting = false;
this.startPing();
};
this.ws.onmessage = (event) => {
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const data: PresenceEvent = JSON.parse(event.data);
this.handleMessage(data);
} catch (e) {
console.error('[GlobalPresence] Failed to parse message:', e);
// Ignore keep-alive comments
if (!event.data.startsWith(':')) {
console.error('[GlobalPresence] Failed to parse message:', e);
}
}
};
this.ws.onclose = () => {
console.log('[GlobalPresence] Disconnected');
this.eventSource.onerror = (error) => {
console.error('[GlobalPresence] SSE error:', error);
this.isConnecting = false;
this.stopPing();
this.disconnect();
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('[GlobalPresence] WebSocket error:', error);
this.isConnecting = false;
};
} catch (e) {
console.error('[GlobalPresence] Failed to create WebSocket:', e);
console.error('[GlobalPresence] Failed to create EventSource:', e);
this.isConnecting = false;
this.scheduleReconnect();
}
}
disconnect(): void {
this.stopPing();
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
@@ -86,8 +98,8 @@ class GlobalPresenceClient {
this.listeners.add(callback);
// Send current state immediately
if (Object.keys(this.presence).length > 0) {
callback(this.presence);
if (this.cityOnline > 0 || Object.keys(this.roomsPresence).length > 0) {
callback(this.cityOnline, this.roomsPresence);
}
// Connect if not connected
@@ -103,62 +115,44 @@ class GlobalPresenceClient {
};
}
getPresence(slug: string): RoomPresence | null {
return this.presence[slug] || null;
getCityOnline(): number {
return this.cityOnline;
}
getAllPresence(): Record<string, RoomPresence> {
return { ...this.presence };
getRoomPresence(roomId: string): RoomPresence | null {
return this.roomsPresence[roomId] || null;
}
private handleMessage(data: any): void {
if (data.type === 'snapshot') {
// Initial snapshot
this.presence = {};
for (const room of data.rooms || []) {
this.presence[room.room_slug] = {
room_slug: room.room_slug,
online_count: room.online_count || 0,
typing_count: room.typing_count || 0
};
}
this.notifyListeners();
} else if (data.type === 'room.presence') {
// Incremental update
this.presence[data.room_slug] = {
room_slug: data.room_slug,
online_count: data.online_count || 0,
typing_count: data.typing_count || 0
};
this.notifyListeners();
getAllRoomsPresence(): Record<string, RoomPresence> {
return { ...this.roomsPresence };
}
private handleMessage(data: PresenceEvent): void {
if (data.type !== 'presence_update') return;
// Update city stats
this.cityOnline = data.city?.online_total || 0;
// Update rooms
const newRoomsPresence: Record<string, RoomPresence> = {};
for (const room of data.rooms || []) {
newRoomsPresence[room.room_id] = room;
}
this.roomsPresence = newRoomsPresence;
this.notifyListeners();
}
private notifyListeners(): void {
for (const callback of this.listeners) {
try {
callback(this.presence);
callback(this.cityOnline, this.roomsPresence);
} catch (e) {
console.error('[GlobalPresence] Listener error:', e);
}
}
}
private startPing(): void {
this.pingInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send('ping');
}
}, 30000);
}
private stopPing(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
private scheduleReconnect(): void {
if (this.reconnectTimeout) return;