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 Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import { PORT } from './config.js';
|
import { PORT } from './config.js';
|
||||||
@@ -5,13 +6,22 @@ import { runMigrations } from './db/migrate.js';
|
|||||||
import { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
import { shipRoutes } from './routes/ships.js';
|
import { shipRoutes } from './routes/ships.js';
|
||||||
import { weaponRoutes } from './routes/weapons.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 () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
runMigrations();
|
runMigrations();
|
||||||
|
|
||||||
|
// Set up Socket.IO on the shared HTTP server
|
||||||
|
setupSocketIO(httpServer);
|
||||||
|
|
||||||
await app.register(cors, { origin: true });
|
await app.register(cors, { origin: true });
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(shipRoutes);
|
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