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 (