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:
Bas van Rossem
2026-02-19 16:19:20 +01:00
parent 525a1a4a95
commit 76ad839abb
4 changed files with 155 additions and 1 deletions

View File

@@ -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);

View 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;
}

View 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 });
}
});
}

View 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,
});
});
}