From 19eba1eebd83ab2b2adb20db571a764cf0c23933 Mon Sep 17 00:00:00 2001 From: francesco448 Date: Wed, 18 Mar 2026 16:53:13 +0100 Subject: [PATCH] Add Express server with WebSocket, CORS, and room routes Set up the HTTP server with Express, attach Socket.IO for real-time messaging, add CORS middleware, async error handling utility, and REST routes for room join/leave/messages/participants. Co-Authored-By: Claude Sonnet 4.6 --- Backend/index.js | 1 + Backend/lib/simpleWebSocket.js | 109 ++++++++++++++++++++++++++++ Backend/middleware/cors.js | 15 ++++ Backend/routes/rooms.js | 126 +++++++++++++++++++++++++++++++++ Backend/server.js | 44 ++++++++++++ Backend/utils/asyncHandler.js | 7 ++ 6 files changed, 302 insertions(+) create mode 100644 Backend/index.js create mode 100644 Backend/lib/simpleWebSocket.js create mode 100644 Backend/middleware/cors.js create mode 100644 Backend/routes/rooms.js create mode 100644 Backend/server.js create mode 100644 Backend/utils/asyncHandler.js diff --git a/Backend/index.js b/Backend/index.js new file mode 100644 index 0000000..43ba18e --- /dev/null +++ b/Backend/index.js @@ -0,0 +1 @@ +require('./server') diff --git a/Backend/lib/simpleWebSocket.js b/Backend/lib/simpleWebSocket.js new file mode 100644 index 0000000..4a1f082 --- /dev/null +++ b/Backend/lib/simpleWebSocket.js @@ -0,0 +1,109 @@ +const { randomUUID } = require('crypto') +const { Server } = require('socket.io') + +function createRealtimeServer(server, { clientOrigin, database, onError }) { + const io = new Server(server, { + cors: { + origin: clientOrigin, + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + }, + }) + + io.use(async (socket, next) => { + try { + const auth = socket.handshake.auth || {} + const query = socket.handshake.query || {} + const room = readParam(auth.room, query.room) + const userId = readParam(auth.userId, query.userId) + + if (!room || !userId) { + return next(new Error('Missing room or userId')) + } + + const user = await database.getUser(userId) + if (!user || user.room !== room) { + return next(new Error('User not authorized for this room')) + } + + socket.data.room = room + socket.data.userId = userId + next() + } catch (error) { + next(error) + } + }) + + io.on('connection', (socket) => { + registerConnection(io, socket, database, onError).catch((error) => { + onError('Socket connection setup failed', error) + socket.disconnect(true) + }) + }) + + return io +} + +async function registerConnection(io, socket, database, onError) { + const { room, userId } = socket.data + socket.join(room) + + const user = await database.setUserOnline(userId, room) + if (!user) { + socket.disconnect(true) + return + } + + socket.emit('connection:ready', { userId, room }) + socket.emit('history:init', await database.getMessages(room)) + await broadcastParticipants(io, database, room) + + socket.on('message:send', async (payload) => { + try { + const text = + typeof payload?.text === 'string' ? payload.text : payload?.payload?.text + if (!text || !text.trim()) { + return + } + + const activeUser = await database.getUser(userId) + if (!activeUser || activeUser.room !== room) { + return + } + + const message = await database.addMessage({ + id: randomUUID(), + room, + userId, + username: activeUser.username, + text: text.trim(), + }) + io.to(room).emit('message:new', message) + } catch (error) { + onError('Failed to handle socket message', error) + } + }) + + socket.on('disconnect', () => { + database + .setUserOffline(userId) + .then(() => broadcastParticipants(io, database, room)) + .catch((error) => onError('Failed to update presence', error)) + }) + + socket.on('error', (error) => { + onError('Socket error', error) + }) +} + +async function broadcastParticipants(io, database, room) { + const participants = await database.getParticipants(room) + io.to(room).emit('participants:update', participants) +} + +function readParam(primary, fallback) { + if (typeof primary === 'string' && primary.trim()) return primary + if (typeof fallback === 'string' && fallback.trim()) return fallback + return null +} + +module.exports = createRealtimeServer diff --git a/Backend/middleware/cors.js b/Backend/middleware/cors.js new file mode 100644 index 0000000..983cf33 --- /dev/null +++ b/Backend/middleware/cors.js @@ -0,0 +1,15 @@ +function createCorsMiddleware(clientOrigin) { + return (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', clientOrigin) + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + return res.sendStatus(204) + } + + next() + } +} + +module.exports = createCorsMiddleware diff --git a/Backend/routes/rooms.js b/Backend/routes/rooms.js new file mode 100644 index 0000000..ae02de1 --- /dev/null +++ b/Backend/routes/rooms.js @@ -0,0 +1,126 @@ +const express = require('express') +const { randomUUID } = require('crypto') +const asyncHandler = require('../utils/asyncHandler') + +function createRoomsRouter({ database, io }) { + const router = express.Router() + + router.post( + '/:room/join', + asyncHandler(async (req, res) => { + const { room } = req.params + const { username, userId } = req.body + + if (!username || !username.trim()) { + return res.status(400).json({ error: 'Username is required' }) + } + + const id = userId || randomUUID() + const user = await database.upsertUser({ + id, + username: username.trim(), + room, + }) + + await broadcastParticipants(database, io, room) + res.json({ user }) + }), + ) + + router.patch( + '/:room/users/:userId', + asyncHandler(async (req, res) => { + const { room, userId } = req.params + const { username } = req.body + + if (!username || !username.trim()) { + return res.status(400).json({ error: 'Username is required' }) + } + + const user = await database.getUser(userId) + if (!user || user.room !== room) { + return res.status(404).json({ error: 'User not found in this room' }) + } + + const updated = await database.updateUsername(userId, username.trim()) + await broadcastParticipants(database, io, room) + res.json({ user: updated }) + }), + ) + + router.post( + '/:room/leave', + asyncHandler(async (req, res) => { + const { room } = req.params + const { userId } = req.body + + if (!userId) { + return res.status(400).json({ error: 'userId is required' }) + } + + const user = await database.getUser(userId) + if (!user || user.room !== room) { + return res.status(404).json({ error: 'User not found in this room' }) + } + + await database.setUserOffline(userId) + await broadcastParticipants(database, io, room) + res.json({ success: true }) + }), + ) + + router.get( + '/:room/messages', + asyncHandler(async (req, res) => { + const { room } = req.params + const messages = await database.getMessages(room) + res.json({ messages }) + }), + ) + + router.get( + '/:room/participants', + asyncHandler(async (req, res) => { + const { room } = req.params + const participants = await database.getParticipants(room) + res.json({ participants }) + }), + ) + + router.post( + '/:room/messages', + asyncHandler(async (req, res) => { + const { room } = req.params + const { userId, text } = req.body + + if (!userId || !text || !text.trim()) { + return res.status(400).json({ error: 'userId and text are required' }) + } + + const user = await database.getUser(userId) + if (!user || user.room !== room) { + return res.status(404).json({ error: 'Unknown user or room mismatch' }) + } + + const message = await database.addMessage({ + id: randomUUID(), + room, + userId, + username: user.username, + text: text.trim(), + }) + + io.to(room).emit('message:new', message) + res.status(201).json({ message }) + }), + ) + + return router +} + +async function broadcastParticipants(database, io, room) { + const participants = await database.getParticipants(room) + io.to(room).emit('participants:update', participants) +} + +module.exports = createRoomsRouter diff --git a/Backend/server.js b/Backend/server.js new file mode 100644 index 0000000..f78c272 --- /dev/null +++ b/Backend/server.js @@ -0,0 +1,44 @@ +const http = require('http') +const express = require('express') +const config = require('./config') +const createDataStore = require('./lib/datastore') +const createRealtimeServer = require('./lib/simpleWebSocket') +const createCorsMiddleware = require('./middleware/cors') +const createRoomsRouter = require('./routes/rooms') + +const app = express() +const server = http.createServer(app) + +app.use(express.json()) +app.use(createCorsMiddleware(config.clientOrigin)) + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', time: new Date().toISOString() }) +}) + +async function bootstrap() { + try { + const database = await createDataStore() + const io = createRealtimeServer(server, { + clientOrigin: config.clientOrigin, + database, + onError: (message, error) => console.error(message, error), + }) + + app.use('/rooms', createRoomsRouter({ database, io })) + + app.use((error, _req, res, _next) => { + console.error('Request failed', error) + res.status(500).json({ error: 'Internal server error' }) + }) + + server.listen(config.port, () => { + console.log(`Chat backend running on http://localhost:${config.port}`) + }) + } catch (error) { + console.error('Unable to start server:', error) + process.exit(1) + } +} + +bootstrap() diff --git a/Backend/utils/asyncHandler.js b/Backend/utils/asyncHandler.js new file mode 100644 index 0000000..b9c3acf --- /dev/null +++ b/Backend/utils/asyncHandler.js @@ -0,0 +1,7 @@ +function asyncHandler(handler) { + return (req, res, next) => { + Promise.resolve(handler(req, res, next)).catch(next) + } +} + +module.exports = asyncHandler