Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e43e6945b | ||
|
|
94a1d9b110 | ||
|
|
9fa5652173 | ||
|
|
3fa6cce17a | ||
|
|
8f3dd9ec92 | ||
|
|
30abf33ee8 | ||
|
|
caa2bea7a8 | ||
|
|
12c3c37ff8 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,3 +1,31 @@
|
||||
## [2.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.5...v2.4.6) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* real-time room member updates via globalThis socket.io sharing ([94a1d9b](https://github.com/antialias/soroban-abacus-flashcards/commit/94a1d9b11058bfb4b54a4753e143cf85f215e913))
|
||||
|
||||
## [2.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.4...v2.4.5) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* send all members (not just online) in socket broadcasts ([3fa6cce](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa6cce17a7acd940cf5a9e6433bf6c4b497540c))
|
||||
|
||||
## [2.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.3...v2.4.4) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correctly access getSocketIO from dynamic import ([30abf33](https://github.com/antialias/soroban-abacus-flashcards/commit/30abf33ee86b36f2a98014e5b017fa8e466a2107))
|
||||
|
||||
## [2.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.2...v2.4.3) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve socket-server import path for Next.js build ([12c3c37](https://github.com/antialias/soroban-abacus-flashcards/commit/12c3c37ff8e1d3df71d72e527c08fa975043c504))
|
||||
|
||||
## [2.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.1...v2.4.2) (2025-10-08)
|
||||
|
||||
|
||||
|
||||
371
apps/web/__tests__/room-realtime-updates.e2e.test.ts
Normal file
371
apps/web/__tests__/room-realtime-updates.e2e.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createServer } from 'http'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { io as ioClient, type Socket } from 'socket.io-client'
|
||||
import { afterEach, beforeEach, describe, expect, it, afterAll, beforeAll } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createRoom } from '../src/lib/arcade/room-manager'
|
||||
import { addRoomMember } from '../src/lib/arcade/room-membership'
|
||||
import { initializeSocketServer } from '../socket-server'
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
|
||||
/**
|
||||
* Real-time Room Updates E2E Tests
|
||||
*
|
||||
* Tests that socket broadcasts work correctly when users join/leave rooms.
|
||||
* Simulates multiple connected users and verifies they receive real-time updates.
|
||||
*/
|
||||
|
||||
describe('Room Real-time Updates', () => {
|
||||
let testUserId1: string
|
||||
let testUserId2: string
|
||||
let testGuestId1: string
|
||||
let testGuestId2: string
|
||||
let testRoomId: string
|
||||
let socket1: Socket
|
||||
let httpServer: any
|
||||
let io: SocketIOServerType
|
||||
let serverPort: number
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create HTTP server and initialize Socket.IO for testing
|
||||
httpServer = createServer()
|
||||
io = initializeSocketServer(httpServer)
|
||||
|
||||
// Find an available port
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, () => {
|
||||
serverPort = (httpServer.address() as any).port
|
||||
console.log(`Test socket server listening on port ${serverPort}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Close all socket connections
|
||||
if (io) {
|
||||
io.close()
|
||||
}
|
||||
if (httpServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test users
|
||||
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
|
||||
|
||||
testUserId1 = user1.id
|
||||
testUserId2 = user2.id
|
||||
|
||||
// Create a test room
|
||||
const room = await createRoom({
|
||||
name: 'Realtime Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Disconnect sockets
|
||||
if (socket1?.connected) {
|
||||
socket1.disconnect()
|
||||
}
|
||||
|
||||
// Clean up room members
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.roomId, testRoomId))
|
||||
|
||||
// Clean up rooms
|
||||
if (testRoomId) {
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
}
|
||||
|
||||
// Clean up users
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
|
||||
})
|
||||
|
||||
it('should broadcast member-joined when a user joins via API', async () => {
|
||||
// User 1 joins the room via API first (this is what happens when they click "Join Room")
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 1 connects to socket
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
// Wait for socket to connect
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
socket1.on('connect_error', (err) => reject(err))
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 2000)
|
||||
})
|
||||
|
||||
// Small delay to ensure event handlers are set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Set up listener for room-joined BEFORE emitting
|
||||
const roomJoinedPromise = new Promise<void>((resolve, reject) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
socket1.on('room-error', (err) => reject(new Error(err.error)))
|
||||
setTimeout(() => reject(new Error('Room-joined timeout')), 3000)
|
||||
})
|
||||
|
||||
// Now emit the join-room event
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
// Wait for confirmation
|
||||
await roomJoinedPromise
|
||||
|
||||
// Set up listener for member-joined event BEFORE User 2 joins
|
||||
const memberJoinedPromise = new Promise<any>((resolve, reject) => {
|
||||
socket1.on('member-joined', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
setTimeout(() => reject(new Error('Timeout waiting for member-joined event')), 3000)
|
||||
})
|
||||
|
||||
// User 2 joins the room via addRoomMember
|
||||
const { member: newMember } = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Manually trigger the broadcast (this is what the API route SHOULD do)
|
||||
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
|
||||
|
||||
const members = await getRoomMembers(testRoomId)
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId)
|
||||
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-joined', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
// Wait for the socket broadcast with timeout
|
||||
const data = await memberJoinedPromise
|
||||
|
||||
// Verify the broadcast data
|
||||
expect(data).toBeDefined()
|
||||
expect(data.roomId).toBe(testRoomId)
|
||||
expect(data.userId).toBe(testGuestId2)
|
||||
expect(data.members).toBeDefined()
|
||||
expect(Array.isArray(data.members)).toBe(true)
|
||||
|
||||
// Verify both users are in the members list
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId1)
|
||||
expect(memberUserIds).toContain(testGuestId2)
|
||||
|
||||
// Verify the new member details
|
||||
const addedMember = data.members.find((m: any) => m.userId === testGuestId2)
|
||||
expect(addedMember).toBeDefined()
|
||||
expect(addedMember.displayName).toBe('User 2')
|
||||
expect(addedMember.roomId).toBe(testRoomId)
|
||||
})
|
||||
|
||||
it('should broadcast member-left when a user leaves via API', async () => {
|
||||
// User 1 joins the room first
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 2 joins the room
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 1 connects to socket
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
})
|
||||
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
})
|
||||
|
||||
// Set up listener for member-left event
|
||||
const memberLeftPromise = new Promise<any>((resolve) => {
|
||||
socket1.on('member-left', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// User 2 leaves the room via API
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2))
|
||||
|
||||
// Manually trigger the leave broadcast (simulating what the API does)
|
||||
const { getSocketIO } = await import('../src/lib/socket-io')
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
|
||||
|
||||
const members = await getRoomMembers(testRoomId)
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId)
|
||||
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-left', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for the socket broadcast with timeout
|
||||
const data = await Promise.race([
|
||||
memberLeftPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout waiting for member-left event')), 2000)
|
||||
),
|
||||
])
|
||||
|
||||
// Verify the broadcast data
|
||||
expect(data).toBeDefined()
|
||||
expect(data.roomId).toBe(testRoomId)
|
||||
expect(data.userId).toBe(testGuestId2)
|
||||
expect(data.members).toBeDefined()
|
||||
expect(Array.isArray(data.members)).toBe(true)
|
||||
|
||||
// Verify User 2 is no longer in the members list
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId1)
|
||||
expect(memberUserIds).not.toContain(testGuestId2)
|
||||
})
|
||||
|
||||
it('should update both members and players lists in member-joined broadcast', async () => {
|
||||
// Create an active player for User 2
|
||||
const [player2] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId2,
|
||||
name: 'Player 2',
|
||||
emoji: '🎮',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// User 1 connects and joins room
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
})
|
||||
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
})
|
||||
|
||||
const memberJoinedPromise = new Promise<any>((resolve) => {
|
||||
socket1.on('member-joined', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// User 2 joins via API
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Manually trigger the broadcast (simulating what the API does)
|
||||
const { getRoomMembers: getRoomMembers3 } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers: getRoomActivePlayers3 } = await import(
|
||||
'../src/lib/arcade/player-manager'
|
||||
)
|
||||
|
||||
const members2 = await getRoomMembers3(testRoomId)
|
||||
const memberPlayers2 = await getRoomActivePlayers3(testRoomId)
|
||||
|
||||
const memberPlayersObj2: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers2.entries()) {
|
||||
memberPlayersObj2[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-joined', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members: members2,
|
||||
memberPlayers: memberPlayersObj2,
|
||||
})
|
||||
|
||||
const data = await Promise.race([
|
||||
memberJoinedPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
|
||||
])
|
||||
|
||||
// Verify members list is updated
|
||||
expect(data.members).toBeDefined()
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId2)
|
||||
|
||||
// Verify players list is updated
|
||||
expect(data.memberPlayers).toBeDefined()
|
||||
expect(data.memberPlayers[testGuestId2]).toBeDefined()
|
||||
expect(Array.isArray(data.memberPlayers[testGuestId2])).toBe(true)
|
||||
|
||||
// User 2's players should include the active player we created
|
||||
const user2Players = data.memberPlayers[testGuestId2]
|
||||
expect(user2Players.length).toBeGreaterThan(0)
|
||||
expect(user2Players.some((p: any) => p.id === player2.id)).toBe(true)
|
||||
|
||||
// Clean up player
|
||||
await db.delete(schema.players).where(eq(schema.players.id, player2.id))
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
const { Server } = require('socket.io')
|
||||
|
||||
function initializeSocketServer(httpServer) {
|
||||
const io = new Server(httpServer, {
|
||||
path: '/api/socket',
|
||||
cors: {
|
||||
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('🔌 Client connected:', socket.id)
|
||||
|
||||
socket.on('join-arcade-session', ({ userId }) => {
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 Client disconnected:', socket.id)
|
||||
})
|
||||
})
|
||||
|
||||
console.log('✅ Socket.IO initialized on /api/socket')
|
||||
return io
|
||||
}
|
||||
|
||||
module.exports = { initializeSocketServer }
|
||||
@@ -9,29 +9,27 @@ import {
|
||||
updateSessionActivity,
|
||||
} from './src/lib/arcade/session-manager'
|
||||
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
|
||||
import {
|
||||
getOnlineRoomMembers,
|
||||
getRoomMembers,
|
||||
getUserRooms,
|
||||
setMemberOnline,
|
||||
} from './src/lib/arcade/room-membership'
|
||||
import { getRoomMembers, getUserRooms, setMemberOnline } from './src/lib/arcade/room-membership'
|
||||
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
|
||||
// Use globalThis to store socket.io instance to avoid module isolation issues
|
||||
// This ensures the same instance is accessible across dynamic imports
|
||||
declare global {
|
||||
var __socketIO: SocketIOServerType | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the socket.io server instance
|
||||
* Returns null if not initialized
|
||||
*/
|
||||
export function getSocketIO(): SocketIOServerType | null {
|
||||
return io
|
||||
return globalThis.__socketIO || null
|
||||
}
|
||||
|
||||
export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
io = new SocketIOServer(httpServer, {
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
path: '/api/socket',
|
||||
cors: {
|
||||
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
|
||||
@@ -155,7 +153,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
// Notify all connected clients about the new session
|
||||
const newSession = await getArcadeSession(data.userId)
|
||||
if (newSession) {
|
||||
io.to(`arcade:${data.userId}`).emit('session-state', {
|
||||
io!.to(`arcade:${data.userId}`).emit('session-state', {
|
||||
gameState: newSession.gameState,
|
||||
currentGame: newSession.currentGame,
|
||||
gameUrl: newSession.gameUrl,
|
||||
@@ -171,7 +169,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
if (result.success && result.session) {
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', {
|
||||
io!.to(`arcade:${data.userId}`).emit('move-accepted', {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
@@ -202,7 +200,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
try {
|
||||
await deleteArcadeSession(userId)
|
||||
io.to(`arcade:${userId}`).emit('session-ended')
|
||||
io!.to(`arcade:${userId}`).emit('session-ended')
|
||||
} catch (error) {
|
||||
console.error('Error ending session:', error)
|
||||
socket.emit('session-error', { error: 'Failed to end session' })
|
||||
@@ -232,7 +230,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
// Get room data
|
||||
const members = await getRoomMembers(roomId)
|
||||
const onlineMembers = await getOnlineRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
@@ -245,7 +242,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
socket.emit('room-joined', {
|
||||
roomId,
|
||||
members,
|
||||
onlineMembers,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
@@ -253,7 +249,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
socket.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId,
|
||||
onlineMembers,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
@@ -275,8 +271,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
// Mark member as offline
|
||||
await setMemberOnline(roomId, userId, false)
|
||||
|
||||
// Get updated online members
|
||||
const onlineMembers = await getOnlineRoomMembers(roomId)
|
||||
// Get updated members
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object
|
||||
@@ -286,10 +282,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
|
||||
// Notify remaining members
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
io!.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId,
|
||||
onlineMembers,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
@@ -314,7 +310,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
|
||||
// Broadcast to all members in the room (including sender)
|
||||
io.to(`room:${roomId}`).emit('room-players-updated', {
|
||||
io!.to(`room:${roomId}`).emit('room-players-updated', {
|
||||
roomId,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
@@ -335,6 +331,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
})
|
||||
})
|
||||
|
||||
// Store in globalThis to make accessible across module boundaries
|
||||
globalThis.__socketIO = io
|
||||
console.log('✅ Socket.IO initialized on /api/socket')
|
||||
return io
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember, getOnlineRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '../../../../../../socket-server'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -58,10 +58,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
await touchRoom(roomId)
|
||||
|
||||
// Broadcast to all users in the room via socket
|
||||
const io = getSocketIO()
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const onlineMembers = await getOnlineRoomMembers(roomId)
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
@@ -74,7 +74,7 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
io.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
onlineMembers,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getOnlineRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '../../../../../../socket-server'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -34,10 +34,10 @@ export async function POST(_req: NextRequest, context: RouteContext) {
|
||||
await removeMember(roomId, viewerId)
|
||||
|
||||
// Broadcast to all remaining users in the room via socket
|
||||
const io = getSocketIO()
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const onlineMembers = await getOnlineRoomMembers(roomId)
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
@@ -50,7 +50,7 @@ export async function POST(_req: NextRequest, context: RouteContext) {
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
onlineMembers,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@ export default function RoomDetailPage() {
|
||||
|
||||
sock.on('member-joined', (data) => {
|
||||
console.log('Member joined:', data)
|
||||
if (data.onlineMembers) {
|
||||
setMembers(data.onlineMembers)
|
||||
if (data.members) {
|
||||
setMembers(data.members)
|
||||
}
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
@@ -93,8 +93,8 @@ export default function RoomDetailPage() {
|
||||
|
||||
sock.on('member-left', (data) => {
|
||||
console.log('Member left:', data)
|
||||
if (data.onlineMembers) {
|
||||
setMembers(data.onlineMembers)
|
||||
if (data.members) {
|
||||
setMembers(data.members)
|
||||
}
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
|
||||
40
apps/web/src/lib/socket-io.ts
Normal file
40
apps/web/src/lib/socket-io.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Socket.IO server instance accessor for API routes
|
||||
* This module provides a way for API routes to access the socket.io server
|
||||
* to broadcast real-time updates.
|
||||
*/
|
||||
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
|
||||
// Cache for the socket server module
|
||||
let socketServerModule: any = null
|
||||
|
||||
/**
|
||||
* Get the socket.io server instance
|
||||
* Returns null if not initialized or if called on client-side
|
||||
*/
|
||||
export async function getSocketIO(): Promise<SocketIOServerType | null> {
|
||||
// Client-side: return null
|
||||
if (typeof window !== 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Lazy-load the socket server module on first call
|
||||
if (!socketServerModule) {
|
||||
try {
|
||||
// Dynamic import to avoid bundling issues
|
||||
socketServerModule = await import('../../socket-server')
|
||||
} catch (error) {
|
||||
console.error('[Socket IO] Failed to load socket server:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Call the exported getSocketIO function from the module
|
||||
if (socketServerModule && typeof socketServerModule.getSocketIO === 'function') {
|
||||
return socketServerModule.getSocketIO()
|
||||
}
|
||||
|
||||
console.warn('[Socket IO] getSocketIO function not found in socket-server module')
|
||||
return null
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.6",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user