diff --git a/server/src/index.ts b/server/src/index.ts index 06444a1..e1faf6e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,3 +1,4 @@ +import { createServer } from 'node:http'; import Fastify from 'fastify'; import cors from '@fastify/cors'; import { PORT } from './config.js'; @@ -5,13 +6,22 @@ import { runMigrations } from './db/migrate.js'; import { healthRoutes } from './routes/health.js'; import { shipRoutes } from './routes/ships.js'; import { weaponRoutes } from './routes/weapons.js'; +import { setupSocketIO } from './socket/index.js'; -const app = Fastify({ logger: true }); +// Create a raw HTTP server so both Fastify and Socket.IO can share it +const httpServer = createServer(); +const app = Fastify({ logger: true, serverFactory: (handler) => { + httpServer.on('request', handler); + return httpServer; +}}); const start = async () => { try { runMigrations(); + // Set up Socket.IO on the shared HTTP server + setupSocketIO(httpServer); + await app.register(cors, { origin: true }); await app.register(healthRoutes); await app.register(shipRoutes); diff --git a/server/src/socket/index.ts b/server/src/socket/index.ts new file mode 100644 index 0000000..9860d69 --- /dev/null +++ b/server/src/socket/index.ts @@ -0,0 +1,25 @@ +import { Server } from 'socket.io'; +import type { Server as HttpServer } from 'node:http'; +import { registerShipEvents } from './ship-events.js'; +import { registerWeaponEvents } from './weapon-events.js'; + +export function setupSocketIO(httpServer: HttpServer): Server { + const io = new Server(httpServer, { + cors: { + origin: true, + }, + }); + + io.on('connection', (socket) => { + console.log(`Client connected: ${socket.id}`); + + registerShipEvents(io, socket); + registerWeaponEvents(io, socket); + + socket.on('disconnect', () => { + console.log(`Client disconnected: ${socket.id}`); + }); + }); + + return io; +} diff --git a/server/src/socket/ship-events.ts b/server/src/socket/ship-events.ts new file mode 100644 index 0000000..1efc129 --- /dev/null +++ b/server/src/socket/ship-events.ts @@ -0,0 +1,49 @@ +import type { Server, Socket } from 'socket.io'; +import { updateShipSchema } from '../validation/ship-schema.js'; +import * as shipService from '../services/ship-service.js'; + +export function registerShipEvents(io: Server, socket: Socket) { + socket.on('ship:join', ({ shipId }: { shipId: string }) => { + socket.join(shipId); + + // Send current state to the joining client + const result = shipService.getShip(shipId); + if (result) { + socket.emit('ship:state', result); + } else { + socket.emit('ship:error', { message: 'Ship not found' }); + } + }); + + socket.on('ship:leave', ({ shipId }: { shipId: string }) => { + socket.leave(shipId); + }); + + socket.on('ship:update', ({ shipId, patch }: { shipId: string; patch: unknown }) => { + const parsed = updateShipSchema.safeParse(patch); + if (!parsed.success) { + socket.emit('ship:error', { + message: 'Validation failed', + details: parsed.error.issues, + }); + return; + } + + try { + const ship = shipService.updateShip(shipId, parsed.data); + if (!ship) { + socket.emit('ship:error', { message: 'Ship not found' }); + return; + } + + // Broadcast to all clients in the room (including sender) + io.to(shipId).emit('ship:patched', { + shipId, + patch: parsed.data, + updated_at: ship.updated_at, + }); + } catch (err: any) { + socket.emit('ship:error', { message: err.message }); + } + }); +} diff --git a/server/src/socket/weapon-events.ts b/server/src/socket/weapon-events.ts new file mode 100644 index 0000000..12b5159 --- /dev/null +++ b/server/src/socket/weapon-events.ts @@ -0,0 +1,70 @@ +import type { Server, Socket } from 'socket.io'; +import { createWeaponSchema, updateWeaponSchema } from '../validation/weapon-schema.js'; +import * as weaponService from '../services/weapon-service.js'; + +export function registerWeaponEvents(io: Server, socket: Socket) { + socket.on( + 'weapon:create', + ({ shipId, weapon }: { shipId: string; weapon: unknown }) => { + const parsed = createWeaponSchema.safeParse(weapon); + if (!parsed.success) { + socket.emit('ship:error', { + message: 'Validation failed', + details: parsed.error.issues, + }); + return; + } + + try { + const created = weaponService.createWeapon(shipId, parsed.data); + io.to(shipId).emit('weapon:created', { shipId, weapon: created }); + } catch (err: any) { + socket.emit('ship:error', { message: err.message }); + } + }, + ); + + socket.on( + 'weapon:update', + ({ weaponId, patch }: { weaponId: string; patch: unknown }) => { + const parsed = updateWeaponSchema.safeParse(patch); + if (!parsed.success) { + socket.emit('ship:error', { + message: 'Validation failed', + details: parsed.error.issues, + }); + return; + } + + try { + const weapon = weaponService.updateWeapon(weaponId, parsed.data); + if (!weapon) { + socket.emit('ship:error', { message: 'Weapon not found' }); + return; + } + + io.to(weapon.ship_id).emit('weapon:patched', { + shipId: weapon.ship_id, + weaponId, + patch: parsed.data, + updated_at: weapon.updated_at, + }); + } catch (err: any) { + socket.emit('ship:error', { message: err.message }); + } + }, + ); + + socket.on('weapon:delete', ({ weaponId }: { weaponId: string }) => { + const result = weaponService.deleteWeapon(weaponId); + if (!result) { + socket.emit('ship:error', { message: 'Weapon not found' }); + return; + } + + io.to(result.shipId).emit('weapon:deleted', { + shipId: result.shipId, + weaponId, + }); + }); +}