From 1a86a3a8b5540bad9c1ed36b0f7405a1d12406c7 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 20 Aug 2026 19:57:01 -0400 Subject: [PATCH] feat(realtime): add channel presence --- src/app/api/realtime/presence/route.ts | 38 ++++++++++ src/app/api/realtime/route.ts | 6 ++ .../frontend/flappy/FlappyArena.tsx | 13 +++- src/components/frontend/flappy/FlappyBird.tsx | 5 +- .../frontend/flappy/FlappyLeaderboard.tsx | 14 +++- src/hooks/useRealtimePresence.ts | 69 +++++++++++++++++++ src/lib/realtime/bus.ts | 2 + src/lib/realtime/presence.ts | 56 +++++++++++++++ 8 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 src/app/api/realtime/presence/route.ts create mode 100644 src/hooks/useRealtimePresence.ts create mode 100644 src/lib/realtime/presence.ts diff --git a/src/app/api/realtime/presence/route.ts b/src/app/api/realtime/presence/route.ts new file mode 100644 index 0000000..e747f3d --- /dev/null +++ b/src/app/api/realtime/presence/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import config from "@payload-config"; +import { getPayload } from "payload"; +import { removePresence, touchPresence } from "@/lib/realtime/presence"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +async function authenticate(req: NextRequest) { + const payload = await getPayload({ config: await config }); + return payload.auth({ headers: req.headers, canSetHeaders: false }); +} + +function channelFrom(req: NextRequest): string | null { + const channel = req.nextUrl.searchParams.get("channel")?.trim(); + return channel && /^[a-z0-9:_-]{1,80}$/i.test(channel) ? channel : null; +} + +export async function POST(req: NextRequest) { + const channel = channelFrom(req); + if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 }); + const { user } = await authenticate(req); + if (!user) return NextResponse.json({ ok: false }, { status: 401 }); + touchPresence(channel, { + id: user.id as number, + name: user.payloadDisplayName || user.username || `Pilot #${user.id}`, + }); + return NextResponse.json({ ok: true }); +} + +export async function DELETE(req: NextRequest) { + const channel = channelFrom(req); + if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 }); + const { user } = await authenticate(req); + if (!user) return NextResponse.json({ ok: false }, { status: 401 }); + removePresence(channel, user.id as number); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/realtime/route.ts b/src/app/api/realtime/route.ts index e170859..2287fcc 100644 --- a/src/app/api/realtime/route.ts +++ b/src/app/api/realtime/route.ts @@ -1,11 +1,13 @@ import { NextRequest } from "next/server"; import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus"; +import { getPresence } from "@/lib/realtime/presence"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export async function GET(req: NextRequest) { const encoder = new TextEncoder(); + const channel = req.nextUrl.searchParams.get("channel"); let client: SSEEventClient | null = null; let heartbeat: ReturnType | null = null; @@ -21,10 +23,14 @@ export async function GET(req: NextRequest) { start(controller) { client = { send: (data) => controller.enqueue(encoder.encode(data)), + events: channel ? new Set(["presence"]) : undefined, }; subscribe(client); controller.enqueue(encoder.encode(": connected\n\n")); + if (channel) { + controller.enqueue(encoder.encode(`event: presence\ndata: ${JSON.stringify({ channel, action: "snapshot", participants: getPresence(channel) })}\n\n`)); + } heartbeat = setInterval(() => { try { diff --git a/src/components/frontend/flappy/FlappyArena.tsx b/src/components/frontend/flappy/FlappyArena.tsx index bd720fa..ff3f69c 100644 --- a/src/components/frontend/flappy/FlappyArena.tsx +++ b/src/components/frontend/flappy/FlappyArena.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import type { FlappyStats } from "@/app/(frontend)/flappy/actions"; +import { useRealtimePresence } from "@/hooks/useRealtimePresence"; import { FlappyBird } from "./FlappyBird"; import { FlappyLeaderboard } from "./FlappyLeaderboard"; @@ -14,10 +15,16 @@ export function FlappyArena({ }) { const [refreshKey, setRefreshKey] = useState(0); + const [playing, setPlaying] = useState(false); + const activePilots = useRealtimePresence("flappy", playing, true); + const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => { + setPlaying(phase === "playing"); + }, []); + return (
- setRefreshKey((k) => k + 1)} /> - + setRefreshKey((k) => k + 1)} onPhaseChange={onPhaseChange} /> +
); } diff --git a/src/components/frontend/flappy/FlappyBird.tsx b/src/components/frontend/flappy/FlappyBird.tsx index 72c5c77..762cac6 100644 --- a/src/components/frontend/flappy/FlappyBird.tsx +++ b/src/components/frontend/flappy/FlappyBird.tsx @@ -324,9 +324,11 @@ function birdHitsPipe(birdX: number, birdY: number, pipe: Pipe): boolean { export function FlappyBird({ initialStats: serverStats, onGameRecorded, + onPhaseChange, }: { initialStats: FlappyStats; onGameRecorded?: () => void; + onPhaseChange?: (phase: Phase) => void; }) { const canvasRef = useRef(null); const wrapperRef = useRef(null); @@ -358,7 +360,8 @@ export function FlappyBird({ const setPhaseSafe = useCallback((p: Phase) => { phaseRef.current = p; setPhase(p); - }, []); + onPhaseChange?.(p); + }, [onPhaseChange]); const reset = useCallback(() => { const g = gameRef.current; diff --git a/src/components/frontend/flappy/FlappyLeaderboard.tsx b/src/components/frontend/flappy/FlappyLeaderboard.tsx index 1fc47e3..d59f76e 100644 --- a/src/components/frontend/flappy/FlappyLeaderboard.tsx +++ b/src/components/frontend/flappy/FlappyLeaderboard.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Trophy } from "lucide-react"; import { cn } from "@/lib/utils"; +import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; interface ApiUser { id: number; @@ -40,6 +41,7 @@ interface LeaderboardRow { interface FlappyLeaderboardProps { refreshKey?: number; currentUserId?: number | string; + activePilots?: PresenceParticipant[]; } function rankClass(index: number) { @@ -49,7 +51,7 @@ function rankClass(index: number) { return "text-muted-foreground"; } -export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeaderboardProps) { +export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots = [] }: FlappyLeaderboardProps) { const [rows, setRows] = useState(null); const [failed, setFailed] = useState(false); @@ -105,6 +107,13 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade

Flight Simulator Leaderboard

Top 10 ยท best run (bird-free) +
+ {activePilots.length}{" "} + {activePilots.length === 1 ? "pilot is" : "pilots are"} flying now + {activePilots.length > 0 && ( + ({activePilots.map((pilot) => pilot.name).join(", ")}) + )} +
{failed ? (

Could not load the leaderboard.

@@ -142,6 +151,9 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade {row.isCurrentUser && ( (you) )} + {activePilots.some((pilot) => pilot.id === row.id) && ( + + )} diff --git a/src/hooks/useRealtimePresence.ts b/src/hooks/useRealtimePresence.ts new file mode 100644 index 0000000..7011686 --- /dev/null +++ b/src/hooks/useRealtimePresence.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface PresenceParticipant { + id: number; + name: string; + lastSeen: string; +} + +interface PresenceEvent { + channel?: string; + action?: "snapshot" | "join" | "update" | "leave"; + participants?: PresenceParticipant[]; + participant?: PresenceParticipant; + participantId?: number; +} + +const HEARTBEAT_MS = 15_000; + +export function useRealtimePresence(channel: string, active: boolean, observe = true): PresenceParticipant[] { + const [participants, setParticipants] = useState([]); + + useEffect(() => { + if (!observe) { + setParticipants([]); + return; + } + + let cancelled = false; + const endpoint = `/api/realtime/presence?channel=${encodeURIComponent(channel)}`; + const send = (method: "POST" | "DELETE") => { + void fetch(endpoint, { method, keepalive: method === "DELETE" }).catch(() => undefined); + }; + const source = new EventSource(`/api/realtime?channel=${encodeURIComponent(channel)}`); + const onPresence = (event: MessageEvent) => { + if (cancelled) return; + let detail: PresenceEvent; + try { + detail = JSON.parse(event.data) as PresenceEvent; + } catch { + return; + } + if (detail.action === "snapshot") setParticipants(detail.participants ?? []); + if ((detail.action === "join" || detail.action === "update") && detail.participant) { + setParticipants((current) => [ + ...current.filter((item) => item.id !== detail.participant!.id), + detail.participant!, + ]); + } + if (detail.action === "leave" && detail.participantId !== undefined) { + setParticipants((current) => current.filter((item) => item.id !== detail.participantId)); + } + }; + source.addEventListener("presence", onPresence); + if (active) send("POST"); + const heartbeat = active ? window.setInterval(() => send("POST"), HEARTBEAT_MS) : undefined; + + return () => { + cancelled = true; + if (heartbeat) window.clearInterval(heartbeat); + source.removeEventListener("presence", onPresence); + source.close(); + if (active) send("DELETE"); + }; + }, [channel, active, observe]); + + return participants; +} diff --git a/src/lib/realtime/bus.ts b/src/lib/realtime/bus.ts index 9096e2b..5a09139 100644 --- a/src/lib/realtime/bus.ts +++ b/src/lib/realtime/bus.ts @@ -1,5 +1,6 @@ export interface SSEEventClient { send: (data: string) => void; + events?: Set; } const clients = new Set(); @@ -20,6 +21,7 @@ export function clientCount(): number { export function broadcast(event: string, data: unknown): number { const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; for (const client of clients) { + if (client.events && !client.events.has(event)) continue; try { client.send(payload); } catch { diff --git a/src/lib/realtime/presence.ts b/src/lib/realtime/presence.ts new file mode 100644 index 0000000..20d8aca --- /dev/null +++ b/src/lib/realtime/presence.ts @@ -0,0 +1,56 @@ +import { broadcast } from "./bus"; + +export interface PresenceParticipant { + id: number; + name: string; + lastSeen: string; +} + +interface PresenceRecord extends PresenceParticipant { + expiresAt: number; +} + +export const PRESENCE_TTL_MS = 45_000; +const channels = new Map>(); + +function prune(channel: string, now = Date.now()): void { + const entries = channels.get(channel); + if (!entries) return; + for (const [id, entry] of entries) { + if (entry.expiresAt <= now) { + entries.delete(id); + broadcast("presence", { channel, action: "leave", participantId: id }); + } + } + if (entries.size === 0) channels.delete(channel); +} + +export function getPresence(channel: string): PresenceParticipant[] { + prune(channel); + return [...(channels.get(channel)?.values() ?? [])].map(({ id, name, lastSeen }) => ({ id, name, lastSeen })); +} + +export function touchPresence(channel: string, participant: Omit): void { + const now = Date.now(); + prune(channel, now); + let entries = channels.get(channel); + if (!entries) { + entries = new Map(); + channels.set(channel, entries); + } + const wasPresent = entries.has(participant.id); + const record = { ...participant, lastSeen: new Date(now).toISOString(), expiresAt: now + PRESENCE_TTL_MS }; + entries.set(participant.id, record); + broadcast("presence", { + channel, + action: wasPresent ? "update" : "join", + participant: { id: record.id, name: record.name, lastSeen: record.lastSeen }, + }); +} + +export function removePresence(channel: string, id: number): void { + const entries = channels.get(channel); + if (!entries?.delete(id)) return; + if (entries.size === 0) channels.delete(channel); + broadcast("presence", { channel, action: "leave", participantId: id }); +}