542 líneas
18 KiB
JavaScript
542 líneas
18 KiB
JavaScript
const express = require('express');
|
|
const http = require('http');
|
|
const socketIo = require('socket.io');
|
|
const cors = require('cors');
|
|
const path = require('path');
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const io = socketIo(server, {
|
|
cors: {
|
|
origin: "*",
|
|
methods: ["GET", "POST"],
|
|
allowedHeaders: ["Content-Type"],
|
|
credentials: true
|
|
},
|
|
transports: ['websocket', 'polling']
|
|
});
|
|
|
|
// Middleware
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Store active users and rooms
|
|
const users = new Map(); // socketId -> user info
|
|
const rooms = new Map(); // roomId -> Set of socketIds
|
|
const userRooms = new Map(); // socketId -> roomId
|
|
|
|
// Default room for all users
|
|
const DEFAULT_ROOM = 'main-chat';
|
|
|
|
// Utility functions
|
|
function getUserInfo(socketId) {
|
|
return users.get(socketId);
|
|
}
|
|
|
|
function addUserToRoom(socketId, roomId = DEFAULT_ROOM) {
|
|
if (!rooms.has(roomId)) {
|
|
rooms.set(roomId, new Set());
|
|
}
|
|
rooms.get(roomId).add(socketId);
|
|
userRooms.set(socketId, roomId);
|
|
}
|
|
|
|
function removeUserFromRoom(socketId) {
|
|
const roomId = userRooms.get(socketId);
|
|
if (roomId && rooms.has(roomId)) {
|
|
rooms.get(roomId).delete(socketId);
|
|
if (rooms.get(roomId).size === 0) {
|
|
rooms.delete(roomId);
|
|
}
|
|
}
|
|
userRooms.delete(socketId);
|
|
}
|
|
|
|
function getRoomUsers(roomId) {
|
|
const roomUsers = [];
|
|
if (rooms.has(roomId)) {
|
|
for (const socketId of rooms.get(roomId)) {
|
|
const user = users.get(socketId);
|
|
if (user) {
|
|
roomUsers.push({
|
|
socketId,
|
|
nickname: user.nickname,
|
|
joinedAt: user.joinedAt
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return roomUsers;
|
|
}
|
|
|
|
function broadcastToRoom(roomId, event, data, exceptSocketId = null) {
|
|
if (rooms.has(roomId)) {
|
|
for (const socketId of rooms.get(roomId)) {
|
|
if (socketId !== exceptSocketId) {
|
|
io.to(socketId).emit(event, data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Socket.IO connection handling
|
|
io.on('connection', (socket) => {
|
|
console.log(`[${new Date().toISOString()}] User connected: ${socket.id}`);
|
|
|
|
// Handle user joining
|
|
socket.on('join-room', (data) => {
|
|
try {
|
|
const { nickname, roomId = DEFAULT_ROOM } = typeof data === 'string'
|
|
? { nickname: data, roomId: DEFAULT_ROOM }
|
|
: data;
|
|
|
|
if (!nickname || nickname.trim().length === 0) {
|
|
socket.emit('error', { message: 'Nickname is required' });
|
|
return;
|
|
}
|
|
|
|
// Check if nickname is already taken in the room
|
|
const roomUsers = getRoomUsers(roomId);
|
|
const nicknameExists = roomUsers.some(user =>
|
|
user.nickname.toLowerCase() === nickname.toLowerCase()
|
|
);
|
|
|
|
if (nicknameExists) {
|
|
socket.emit('error', {
|
|
message: 'Nickname already taken in this room',
|
|
code: 'NICKNAME_TAKEN'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Store user information
|
|
const userInfo = {
|
|
nickname: nickname.trim(),
|
|
roomId,
|
|
joinedAt: new Date().toISOString(),
|
|
isVideoEnabled: true,
|
|
isAudioEnabled: true
|
|
};
|
|
|
|
users.set(socket.id, userInfo);
|
|
addUserToRoom(socket.id, roomId);
|
|
|
|
// Join socket room
|
|
socket.join(roomId);
|
|
|
|
// Notify user they successfully joined
|
|
socket.emit('joined-room', {
|
|
roomId,
|
|
nickname: userInfo.nickname,
|
|
users: getRoomUsers(roomId)
|
|
});
|
|
|
|
// Notify others in the room
|
|
broadcastToRoom(roomId, 'user-joined', {
|
|
socketId: socket.id,
|
|
nickname: userInfo.nickname,
|
|
joinedAt: userInfo.joinedAt
|
|
}, socket.id);
|
|
|
|
console.log(`[${new Date().toISOString()}] ${userInfo.nickname} joined room: ${roomId}`);
|
|
|
|
} catch (error) {
|
|
console.error('Error in join-room:', error);
|
|
socket.emit('error', { message: 'Failed to join room' });
|
|
}
|
|
});
|
|
|
|
// Handle WebRTC signaling - Offer
|
|
socket.on('offer', (data) => {
|
|
try {
|
|
const user = getUserInfo(socket.id);
|
|
if (!user) {
|
|
socket.emit('error', { message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const { targetSocketId, offer } = data;
|
|
|
|
if (targetSocketId) {
|
|
// Send to specific user
|
|
io.to(targetSocketId).emit('offer', {
|
|
fromSocketId: socket.id,
|
|
fromNickname: user.nickname,
|
|
offer
|
|
});
|
|
} else {
|
|
// Broadcast to room (for group calls)
|
|
broadcastToRoom(user.roomId, 'offer', {
|
|
fromSocketId: socket.id,
|
|
fromNickname: user.nickname,
|
|
offer
|
|
}, socket.id);
|
|
}
|
|
|
|
console.log(`[${new Date().toISOString()}] Offer from ${user.nickname} to ${targetSocketId || 'room'}`);
|
|
|
|
} catch (error) {
|
|
console.error('Error in offer:', error);
|
|
socket.emit('error', { message: 'Failed to send offer' });
|
|
}
|
|
});
|
|
|
|
// Handle WebRTC signaling - Answer
|
|
socket.on('answer', (data) => {
|
|
try {
|
|
const user = getUserInfo(socket.id);
|
|
if (!user) {
|
|
socket.emit('error', { message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const { targetSocketId, answer } = data;
|
|
|
|
if (targetSocketId) {
|
|
io.to(targetSocketId).emit('answer', {
|
|
fromSocketId: socket.id,
|
|
fromNickname: user.nickname,
|
|
answer
|
|
});
|
|
}
|
|
|
|
console.log(`[${new Date().toISOString()}] Answer from ${user.nickname} to ${targetSocketId}`);
|
|
|
|
} catch (error) {
|
|
console.error('Error in answer:', error);
|
|
socket.emit('error', { message: 'Failed to send answer' });
|
|
}
|
|
});
|
|
|
|
// Handle WebRTC signaling - ICE Candidate
|
|
socket.on('ice-candidate', (data) => {
|
|
try {
|
|
const user = getUserInfo(socket.id);
|
|
if (!user) {
|
|
socket.emit('error', { message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const { targetSocketId, candidate } = data;
|
|
|
|
if (targetSocketId) {
|
|
io.to(targetSocketId).emit('ice-candidate', {
|
|
fromSocketId: socket.id,
|
|
fromNickname: user.nickname,
|
|
candidate
|
|
});
|
|
} else {
|
|
// Broadcast to room
|
|
broadcastToRoom(user.roomId, 'ice-candidate', {
|
|
fromSocketId: socket.id,
|
|
fromNickname: user.nickname,
|
|
candidate
|
|
}, socket.id);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error in ice-candidate:', error);
|
|
socket.emit('error', { message: 'Failed to send ICE candidate' });
|
|
}
|
|
});
|
|
|
|
// Handle media state changes
|
|
socket.on('media-state', (data) => {
|
|
try {
|
|
const user = getUserInfo(socket.id);
|
|
if (!user) {
|
|
socket.emit('error', { message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const { isVideoEnabled, isAudioEnabled } = data;
|
|
|
|
// Update user state
|
|
user.isVideoEnabled = isVideoEnabled;
|
|
user.isAudioEnabled = isAudioEnabled;
|
|
|
|
// Broadcast to room
|
|
broadcastToRoom(user.roomId, 'user-media-state', {
|
|
socketId: socket.id,
|
|
nickname: user.nickname,
|
|
isVideoEnabled,
|
|
isAudioEnabled
|
|
}, socket.id);
|
|
|
|
console.log(`[${new Date().toISOString()}] ${user.nickname} media state - Video: ${isVideoEnabled}, Audio: ${isAudioEnabled}`);
|
|
|
|
} catch (error) {
|
|
console.error('Error in media-state:', error);
|
|
}
|
|
});
|
|
|
|
// Handle chat messages (backup for data channel failures)
|
|
socket.on('chat-message', (data) => {
|
|
try {
|
|
const user = getUserInfo(socket.id);
|
|
if (!user) {
|
|
socket.emit('error', { message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const { message } = data;
|
|
if (!message || message.trim().length === 0) {
|
|
return;
|
|
}
|
|
|
|
const messageData = {
|
|
fromSocketId: socket.id,
|
|
fromNickname: user.nickname,
|
|
message: message.trim(),
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
// Broadcast to room
|
|
broadcastToRoom(user.roomId, 'chat-message', messageData, socket.id);
|
|
|
|
console.log(`[${new Date().toISOString()}] Message from ${user.nickname}: ${message}`);
|
|
|
|
} catch (error) {
|
|
console.error('Error in chat-message:', error);
|
|
}
|
|
});
|
|
|
|
// Handle room list request
|
|
socket.on('get-rooms', () => {
|
|
try {
|
|
const roomList = [];
|
|
for (const [roomId, userSet] of rooms.entries()) {
|
|
roomList.push({
|
|
roomId,
|
|
userCount: userSet.size,
|
|
users: getRoomUsers(roomId).map(u => ({
|
|
nickname: u.nickname,
|
|
joinedAt: u.joinedAt
|
|
}))
|
|
});
|
|
}
|
|
socket.emit('rooms-list', roomList);
|
|
} catch (error) {
|
|
console.error('Error getting rooms:', error);
|
|
socket.emit('error', { message: 'Failed to get rooms' });
|
|
}
|
|
});
|
|
|
|
// Handle user list request for current room
|
|
socket.on('get-room-users', () => {
|
|
try {
|
|
const user = getUserInfo(socket.id);
|
|
if (user) {
|
|
const roomUsers = getRoomUsers(user.roomId);
|
|
socket.emit('room-users', {
|
|
roomId: user.roomId,
|
|
users: roomUsers
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting room users:', error);
|
|
}
|
|
});
|
|
|
|
// Handle ping for connection monitoring
|
|
socket.on('ping', () => {
|
|
socket.emit('pong', { timestamp: Date.now() });
|
|
});
|
|
|
|
// Handle disconnection
|
|
socket.on('disconnect', (reason) => {
|
|
try {
|
|
const user = getUserInfo(socket.id);
|
|
if (user) {
|
|
// Notify others in the room
|
|
broadcastToRoom(user.roomId, 'user-left', {
|
|
socketId: socket.id,
|
|
nickname: user.nickname,
|
|
reason
|
|
});
|
|
|
|
console.log(`[${new Date().toISOString()}] ${user.nickname} left room: ${user.roomId} (${reason})`);
|
|
|
|
// Clean up
|
|
removeUserFromRoom(socket.id);
|
|
users.delete(socket.id);
|
|
} else {
|
|
console.log(`[${new Date().toISOString()}] Unknown user disconnected: ${socket.id} (${reason})`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error handling disconnect:', error);
|
|
}
|
|
});
|
|
|
|
// Handle errors
|
|
socket.on('error', (error) => {
|
|
console.error(`[${new Date().toISOString()}] Socket error for ${socket.id}:`, error);
|
|
});
|
|
});
|
|
|
|
// REST API endpoints
|
|
app.get('/api/status', (req, res) => {
|
|
res.json({
|
|
status: 'running',
|
|
timestamp: new Date().toISOString(),
|
|
connectedUsers: users.size,
|
|
activeRooms: rooms.size,
|
|
uptime: process.uptime()
|
|
});
|
|
});
|
|
|
|
app.get('/api/rooms', (req, res) => {
|
|
const roomList = [];
|
|
for (const [roomId, userSet] of rooms.entries()) {
|
|
roomList.push({
|
|
roomId,
|
|
userCount: userSet.size,
|
|
users: getRoomUsers(roomId).map(u => ({
|
|
nickname: u.nickname,
|
|
joinedAt: u.joinedAt
|
|
}))
|
|
});
|
|
}
|
|
res.json(roomList);
|
|
});
|
|
|
|
app.get('/api/rooms/:roomId', (req, res) => {
|
|
const { roomId } = req.params;
|
|
if (rooms.has(roomId)) {
|
|
res.json({
|
|
roomId,
|
|
userCount: rooms.get(roomId).size,
|
|
users: getRoomUsers(roomId)
|
|
});
|
|
} else {
|
|
res.status(404).json({ error: 'Room not found' });
|
|
}
|
|
});
|
|
|
|
// Health check endpoint
|
|
app.get('/health', (req, res) => {
|
|
res.status(200).json({
|
|
status: 'healthy',
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
});
|
|
|
|
// Serve a simple test page
|
|
app.get('/', (req, res) => {
|
|
res.send(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>ChatRTC Signaling Server</title>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
|
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
|
h1 { color: #2196F3; }
|
|
.status { background: #e8f5e8; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
|
.endpoint { background: #f8f9fa; padding: 10px; margin: 10px 0; border-left: 4px solid #2196F3; }
|
|
code { background: #f0f0f0; padding: 2px 5px; border-radius: 3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🚀 ChatRTC Signaling Server</h1>
|
|
<div class="status">
|
|
<strong>✅ Server is running successfully!</strong><br>
|
|
Connected Users: <span id="users">${users.size}</span><br>
|
|
Active Rooms: <span id="rooms">${rooms.size}</span><br>
|
|
Uptime: <span id="uptime">${Math.floor(process.uptime())}s</span>
|
|
</div>
|
|
|
|
<h2>📡 API Endpoints</h2>
|
|
<div class="endpoint">
|
|
<strong>GET /api/status</strong> - Server status and statistics
|
|
</div>
|
|
<div class="endpoint">
|
|
<strong>GET /api/rooms</strong> - List all active rooms
|
|
</div>
|
|
<div class="endpoint">
|
|
<strong>GET /api/rooms/:roomId</strong> - Get specific room info
|
|
</div>
|
|
<div class="endpoint">
|
|
<strong>GET /health</strong> - Health check endpoint
|
|
</div>
|
|
|
|
<h2>🔧 Socket.IO Events</h2>
|
|
<p>The server handles the following Socket.IO events for WebRTC signaling:</p>
|
|
<ul>
|
|
<li><code>join-room</code> - Join a chat room</li>
|
|
<li><code>offer</code> - WebRTC offer signaling</li>
|
|
<li><code>answer</code> - WebRTC answer signaling</li>
|
|
<li><code>ice-candidate</code> - ICE candidate exchange</li>
|
|
<li><code>media-state</code> - Video/audio state updates</li>
|
|
<li><code>chat-message</code> - Text message fallback</li>
|
|
</ul>
|
|
|
|
<h2>📱 Android App Connection</h2>
|
|
<p>Update your Android app's WebRTCManager.java with this server URL:</p>
|
|
<div class="endpoint">
|
|
<code>private static final String SIGNALING_SERVER_URL = "http://YOUR_SERVER_IP:${process.env.PORT || 3000}";</code>
|
|
</div>
|
|
|
|
<p><em>Server started at: ${new Date().toISOString()}</em></p>
|
|
</div>
|
|
|
|
<script>
|
|
// Auto-refresh stats every 5 seconds
|
|
setInterval(async () => {
|
|
try {
|
|
const response = await fetch('/api/status');
|
|
const data = await response.json();
|
|
document.getElementById('users').textContent = data.connectedUsers;
|
|
document.getElementById('rooms').textContent = data.activeRooms;
|
|
document.getElementById('uptime').textContent = Math.floor(data.uptime) + 's';
|
|
} catch (error) {
|
|
console.error('Failed to update stats:', error);
|
|
}
|
|
}, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
|
|
// Error handling
|
|
app.use((err, req, res, next) => {
|
|
console.error('Express error:', err);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', () => {
|
|
console.log('SIGTERM received, shutting down gracefully');
|
|
server.close(() => {
|
|
console.log('Server closed');
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('SIGINT received, shutting down gracefully');
|
|
server.close(() => {
|
|
console.log('Server closed');
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
// Start server
|
|
const PORT = process.env.PORT || 3000;
|
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log('🚀 ChatRTC Signaling Server Started');
|
|
console.log(`📡 Server running on http://${HOST}:${PORT}`);
|
|
console.log(`🌐 Socket.IO enabled with CORS`);
|
|
console.log(`📱 Ready for Android app connections`);
|
|
console.log(`⏰ Started at: ${new Date().toISOString()}`);
|
|
});
|
|
|
|
module.exports = { app, server, io };
|