Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"http-status-codes": "2.3.0",
"jsonwebtoken": "9.0.2",
"mongoose": "7.6.3",
"socket.io": "4.7.2",
"uuid": "9.0.1",
"winston": "3.11.0"
},
Expand Down
47 changes: 47 additions & 0 deletions src/middlewares/socketAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Socket, Namespace } from 'socket.io';
import authService from '../services/authService';
import logger from '../config/logger';

export interface AuthenticatedSocket extends Socket {
data: { user?: any };
}

/**
* Socket.io middleware to authenticate connections using JWT.
* Expects token to be provided in `socket.handshake.auth.token` or `socket.handshake.query.token`.
*/
const socketAuth = async (socket: Socket, next: (err?: any) => void) => {
try {
const token =
// prefer auth payload
(socket.handshake &&
(socket.handshake as any).auth &&
(socket.handshake as any).auth.token) ||
// fallback to query string
(socket.handshake &&
(socket.handshake as any).query &&
(socket.handshake as any).query.token);

if (!token) {
logger.warn('Socket auth failed: missing token');
return next(new Error('Unauthorized'));
}

const { userId } = authService.verifyToken(token as string);
const user = await authService.getUserById(userId);
if (!user) {
logger.warn('Socket auth failed: user not found');
return next(new Error('Unauthorized'));
}

// Attach user to socket data for downstream handlers
(socket as AuthenticatedSocket).data = { ...(socket as any).data, user };

return next();
} catch (err) {
logger.warn('Socket auth verification error', err);
return next(new Error('Unauthorized'));
}
};

export default socketAuth;
22 changes: 22 additions & 0 deletions src/models/ChatMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import mongoose, { Document, Model } from 'mongoose';

export interface IChatMessage extends Document {
content: string;
sender?: string;
createdAt: Date;
}

const ChatMessageSchema = new mongoose.Schema(
{
content: { type: String, required: true },
sender: { type: String },
createdAt: { type: Date, default: Date.now },
},
{ versionKey: false },
);

const ChatMessage =
(mongoose.models.ChatMessage as Model<IChatMessage>) ||
mongoose.model<IChatMessage>('ChatMessage', ChatMessageSchema);

export default ChatMessage;
23 changes: 23 additions & 0 deletions src/models/User.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import mongoose, { Document, Model } from 'mongoose';

export interface IUser extends Document {
email: string;
name?: string;
role?: string;
password?: string;
createdAt: Date;
}

const UserSchema = new mongoose.Schema(
{
email: { type: String, required: true, unique: true },
name: { type: String },
role: { type: String, default: 'user' },
password: { type: String },
},
{ timestamps: { createdAt: 'createdAt', updatedAt: false }, versionKey: false },
);

const User = (mongoose.models.User as Model<IUser>) || mongoose.model<IUser>('User', UserSchema);

export default User;
17 changes: 17 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import app from './app';
import logger from './config/logger';
import initSocket from './sockets';

const PORT = process.env.PORT || 3000;

Expand All @@ -8,12 +9,28 @@ const server = app.listen(PORT, () => {
logger.info(`📝 Health check: http://localhost:${PORT}/health`);
});

// Initialize Socket.io
const io = initSocket(server);

// Graceful shutdown
const gracefulShutdown = (): void => {
logger.info('Received shutdown signal, closing gracefully...');

// Close HTTP server
server.close(() => {
logger.info('HTTP server closed');

// Close socket.io if present
try {
if (io && typeof io.close === 'function') {
// close all sockets
// @ts-ignore
io.close(() => logger.info('Socket.io server closed'));
}
} catch (err) {
logger.warn('Error while closing Socket.io', err);
}

import('mongoose').then(({ default: mongoose }) => {
mongoose.connection.close(false).then(() => {
logger.info('MongoDB connection closed');
Expand Down
26 changes: 26 additions & 0 deletions src/services/authService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import jwt from 'jsonwebtoken';
import logger from '../config/logger';
import User, { IUser } from '../models/User';

const JWT_SECRET = process.env.JWT_SECRET || 'change_me_in_prod';

class AuthService {
public verifyToken(token: string): { userId: string } {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { sub?: string } | null;
if (!decoded) throw new Error('Invalid token');
const userId = (decoded as any).sub || (decoded as any).id || (decoded as any)._id;
if (!userId) throw new Error('Token missing subject');
return { userId };
} catch (error) {
logger.warn('JWT verification failed', error);
throw error;
}
}

public async getUserById(id: string): Promise<IUser | null> {
return User.findById(id).lean().exec() as unknown as IUser | null;
}
}

export default new AuthService();
29 changes: 29 additions & 0 deletions src/sockets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Server } from 'socket.io';
import registerSocketHandlers from './socketController';
import logger from '../config/logger';
import socketAuth from '../middlewares/socketAuth';

export const initSocket = (httpServer: any) => {
const io = new Server(httpServer, {
path: '/socket.io',
cors: {
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST'],
},
});

const nsp = io.of('/api/v1/realtime');

// Attach authentication middleware to namespace
nsp.use((socket, next) => socketAuth(socket as any, next as any));

nsp.on('connection', (socket) => {
registerSocketHandlers(socket, nsp);
});

logger.info('✅ Socket.io initialized on namespace /api/v1/realtime');

return io;
};

export default initSocket;
28 changes: 28 additions & 0 deletions src/sockets/socketController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Namespace, Socket } from 'socket.io';
import socketService from './socketService';
import logger from '../config/logger';

const registerSocketHandlers = (socket: Socket, nsp: Namespace): void => {
logger.info(`Socket connected: ${socket.id} to namespace ${nsp.name}`);

socketService.handleConnection(socket, nsp);

socket.on('message', async (payload) => {
try {
await socketService.handleIncomingMessage(nsp, payload);
} catch (err) {
logger.error('Socket message handler error', err);
socket.emit('error', { message: 'Failed to handle message' });
}
});

socket.on('disconnect', (reason) => {
logger.info(`Socket disconnected: ${socket.id} reason: ${reason}`);
});

socket.on('error', (err) => {
logger.error(`Socket error on ${socket.id}:`, err);
});
};

export default registerSocketHandlers;
41 changes: 41 additions & 0 deletions src/sockets/socketService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import ChatMessage, { IChatMessage } from '../models/ChatMessage';
import logger from '../config/logger';
import { Namespace, Socket } from 'socket.io';

class SocketService {
public async getRecentMessages(limit = 10): Promise<IChatMessage[]> {
return ChatMessage.find()
.sort({ createdAt: -1 })
.limit(limit)
.lean()
.exec() as unknown as IChatMessage[];
}

public async saveMessage(payload: { content: string; sender?: string }) {
return ChatMessage.create({ content: payload.content, sender: payload.sender });
}

public async handleConnection(socket: Socket, nsp: Namespace): Promise<void> {
try {
const recent = await this.getRecentMessages();
socket.emit('recentMessages', recent.reverse());
} catch (error) {
logger.error('Error fetching recent messages', error);
socket.emit('error', { message: 'Failed to load recent messages' });
}
}

public async handleIncomingMessage(
nsp: Namespace,
payload: { content: string; sender?: string },
): Promise<void> {
try {
const doc = await this.saveMessage(payload);
nsp.emit('message', doc);
} catch (error) {
logger.error('Error saving message', error);
}
}
}

export default new SocketService();