feat(server): add Socket.IO with ship rooms and real-time events
Implements ship:join/leave/update and weapon:create/update/delete socket events with room-based broadcasting. Server validates and persists before broadcasting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
25
server/src/socket/index.ts
Normal file
25
server/src/socket/index.ts
Normal file
@@ -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;
|
||||
}
|
||||
49
server/src/socket/ship-events.ts
Normal file
49
server/src/socket/ship-events.ts
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
70
server/src/socket/weapon-events.ts
Normal file
70
server/src/socket/weapon-events.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user