fix: broadcast member join/leave events immediately via API

Add socket.io broadcasts to join and leave API routes to notify
all users in a room immediately when someone joins or leaves,
without waiting for socket connection.

Previously:
- API added member to database
- No socket event until user's browser connected
- Other users didn't see new member until page reload
- Players showed up but member didn't (timing race condition)

Now:
- API broadcasts `member-joined` immediately after adding member
- API broadcasts `member-left` immediately after removing member
- All connected users get real-time updates instantly
- Both members list and players list update simultaneously

Changes:
- Export `getSocketIO()` from socket-server.ts for API access
- Join route broadcasts member-joined with updated members/players
- Leave route broadcasts member-left with updated members/players
- Socket broadcasts are non-blocking (won't fail the request)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-08 08:51:04 -05:00
parent 7eb55899c8
commit ebfc88c5ea
4 changed files with 78 additions and 5 deletions

View File

@@ -32,7 +32,9 @@
"Bash(git commit -m \"$(cat <<''EOF''\nfeat: add Biome + ESLint linting setup\n\nAdd Biome for formatting and general linting, with minimal ESLint\nconfiguration for React Hooks rules only. This provides:\n\n- Fast formatting via Biome (10-100x faster than Prettier)\n- General JS/TS linting via Biome\n- React Hooks validation via ESLint (rules-of-hooks)\n- Import organization via Biome\n\nConfiguration files:\n- biome.jsonc: Biome config with custom rule overrides\n- eslint.config.js: Minimal flat config for React Hooks only\n- .gitignore: Added Biome cache exclusion\n- LINTING.md: Documentation for the setup\n\nScripts added to package.json:\n- npm run lint: Check all files\n- npm run lint:fix: Auto-fix issues\n- npm run format: Format all files\n- npm run check: Full Biome check\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git commit:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run:*)"
"Bash(npm run:*)",
"Bash(git pull:*)",
"Bash(git stash:*)"
],
"deny": [],
"ask": []

View File

@@ -1,5 +1,6 @@
import type { Server as HTTPServer } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Server as SocketIOServerType } from 'socket.io'
import {
applyGameMove,
createArcadeSession,
@@ -18,8 +19,19 @@ import { getRoomActivePlayers } from './src/lib/arcade/player-manager'
import type { GameMove, GameName } from './src/lib/arcade/validation'
import { matchingGameValidator } from './src/lib/arcade/validation/MatchingGameValidator'
// Global socket.io server instance
let io: SocketIOServerType | null = null
/**
* Get the socket.io server instance
* Returns null if not initialized
*/
export function getSocketIO(): SocketIOServerType | null {
return io
}
export function initializeSocketServer(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
io = new SocketIOServer(httpServer, {
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',

View File

@@ -1,8 +1,9 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember } from '@/lib/arcade/room-membership'
import { getActivePlayers } from '@/lib/arcade/player-manager'
import { addRoomMember, getOnlineRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '../../../../../../socket-server'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -56,6 +57,34 @@ export async function POST(req: NextRequest, context: RouteContext) {
// Update room activity to refresh TTL
await touchRoom(roomId)
// Broadcast to all users in the room via socket
const io = getSocketIO()
if (io) {
try {
const onlineMembers = await getOnlineRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Broadcast to all users in this room
io.to(`room:${roomId}`).emit('member-joined', {
roomId,
userId: viewerId,
onlineMembers,
memberPlayers: memberPlayersObj,
})
console.log(`[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails
console.error('[Join API] Failed to broadcast member-joined:', socketError)
}
}
// Build response with auto-leave info if applicable
return NextResponse.json(
{

View File

@@ -1,7 +1,9 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById } from '@/lib/arcade/room-manager'
import { isMember, removeMember } from '@/lib/arcade/room-membership'
import { getOnlineRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '../../../../../../socket-server'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -31,6 +33,34 @@ export async function POST(_req: NextRequest, context: RouteContext) {
// Remove member
await removeMember(roomId, viewerId)
// Broadcast to all remaining users in the room via socket
const io = getSocketIO()
if (io) {
try {
const onlineMembers = await getOnlineRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Broadcast to all users in this room
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId: viewerId,
onlineMembers,
memberPlayers: memberPlayersObj,
})
console.log(`[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails
console.error('[Leave API] Failed to broadcast member-left:', socketError)
}
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to leave room:', error)