From c9be49d988a5e314347f191279903bc323de1f46 Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Sat, 3 Jan 2026 22:07:34 +0100 Subject: [PATCH] feat: implement initial Deno server with WebSocket API, static file serving, and development Docker Compose. --- docker-compose.dev.yml | 44 ++++++++++++++++ src/server-deno/application/TrackService.ts | 49 +++++++++++++++--- src/server-deno/main.ts | 32 +++++++----- .../presentation/WebSocketServer.ts | 46 +++++++++++++---- src/server-deno/public/index.html | 41 +++++++++++---- src/server-deno/public/js/handlers.js | 26 +++++++--- src/server-deno/public/js/render.js | 51 ++++++++++++++----- src/server-deno/public/js/session.js | 4 +- src/server-deno/public/js/ui.js | 18 +++++-- src/server-deno/public/js/ws.js | 15 ++++-- src/server-deno/shared/constants.ts | 1 + 11 files changed, 258 insertions(+), 69 deletions(-) create mode 100644 docker-compose.dev.yml diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..7757d0d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,44 @@ +# Development Docker Compose for Hitstar +# Enables hot reload and debugging for local development +# +# Usage: +# docker compose -f docker-compose.dev.yml up --build +# +# Debugging: +# Connect to chrome://inspect or VS Code debugger at localhost:9229 + +services: + hitstar-dev: + build: + context: . + dockerfile: Dockerfile + target: development + image: hitstar-deno:dev + container_name: hitstar-dev + environment: + - DENO_ENV=development + - PORT=5173 + ports: + # Application port + - "5173:5173" + # Deno inspector/debugger port + - "9229:9229" + volumes: + # Mount source code for hot reload + - ./src/server-deno:/app:cached + # Mount data directory + - ./data:/app/data + # Override CMD to enable debugging with inspector + command: > + deno run --allow-net --allow-read --allow-env --allow-write --watch --inspect=0.0.0.0:9229 main.ts + networks: + - hitstar-dev-network + # Restart on crash during development + restart: unless-stopped + # Enable stdin for interactive debugging + stdin_open: true + tty: true + +networks: + hitstar-dev-network: + driver: bridge diff --git a/src/server-deno/application/TrackService.ts b/src/server-deno/application/TrackService.ts index 848c34f..bb8d345 100644 --- a/src/server-deno/application/TrackService.ts +++ b/src/server-deno/application/TrackService.ts @@ -11,7 +11,7 @@ export class TrackService { constructor( private readonly fileSystem: FileSystemService, private readonly metadata: MetadataService, - ) {} + ) { } /** * Get list of available playlists @@ -24,7 +24,7 @@ export class TrackService { // Check root directory for default playlist const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i'); const rootFiles = await this.fileSystem.listFiles(dataDir, audioPattern); - + if (rootFiles.length > 0) { playlists.push({ id: 'default', @@ -35,11 +35,11 @@ export class TrackService { // Check subdirectories const subdirs = await this.fileSystem.listDirectories(dataDir); - + for (const dir of subdirs) { const dirPath = this.fileSystem.getPlaylistDir(dir); const dirFiles = await this.fileSystem.listFiles(dirPath, audioPattern); - + if (dirFiles.length > 0) { playlists.push({ id: dir, @@ -82,9 +82,46 @@ export class TrackService { async reloadYearsIndex(playlistId: string = 'default'): Promise<{ count: number }> { const yearsIndex = await this.metadata.loadYearsIndex(playlistId); const count = Object.keys(yearsIndex).length; - + logger.info(`Reloaded years index for playlist '${playlistId}': ${count} entries`); - + return { count }; } + + /** + * Preload all playlists into cache at startup + * This prevents slow loading when the first game starts + */ + async preloadAllPlaylists(): Promise { + const startTime = Date.now(); + logger.info('🎵 Starting playlist preload...'); + + try { + const playlists = await this.getAvailablePlaylists(); + + if (playlists.length === 0) { + logger.warn('⚠️ No playlists found to preload'); + return; + } + + logger.info(`📋 Found ${playlists.length} playlist(s) to preload`); + + let totalTracks = 0; + for (const playlist of playlists) { + const playlistStart = Date.now(); + const tracks = await this.loadPlaylistTracks(playlist.id); + const playlistDuration = Date.now() - playlistStart; + + totalTracks += tracks.length; + logger.info(` ✓ Loaded playlist '${playlist.name}': ${tracks.length} tracks in ${playlistDuration}ms`); + } + + const totalDuration = Date.now() - startTime; + logger.info(`✅ Playlist preload complete: ${totalTracks} total tracks from ${playlists.length} playlist(s) in ${totalDuration}ms`); + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`❌ Playlist preload failed after ${duration}ms: ${error}`); + } + } } + diff --git a/src/server-deno/main.ts b/src/server-deno/main.ts index f5d7820..399c0ed 100644 --- a/src/server-deno/main.ts +++ b/src/server-deno/main.ts @@ -58,6 +58,10 @@ async function main() { logger.info('Application services initialized'); + // Preload playlists in the background (non-blocking) + // This ensures fast game starts by caching track metadata + trackService.preloadAllPlaylists(); + // Initialize WebSocket server const wsServer = new WebSocketServer(roomService, gameService); wsServer.initialize(config.corsOrigin); @@ -65,22 +69,22 @@ async function main() { // Create combined handler const handler = async (request: Request, info: Deno.ServeHandlerInfo): Promise => { const url = new URL(request.url); - + // Socket.IO requests if (url.pathname.startsWith('/socket.io/')) { // Convert ServeHandlerInfo to ConnInfo format for Socket.IO compatibility // Socket.IO 0.2.0 expects old ConnInfo format with localAddr and remoteAddr const connInfo = { - localAddr: { - transport: 'tcp' as const, - hostname: config.host, - port: config.port + localAddr: { + transport: 'tcp' as const, + hostname: config.host, + port: config.port }, remoteAddr: info.remoteAddr, }; return await wsServer.getHandler()(request, connInfo); } - + // API endpoints if (url.pathname.startsWith('/api/')) { if (url.pathname === '/api/playlists') { @@ -89,7 +93,7 @@ async function main() { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, }); } - + if (url.pathname === '/api/tracks') { const playlistId = url.searchParams.get('playlist') || 'default'; const tracks = await trackService.loadPlaylistTracks(playlistId); @@ -97,7 +101,7 @@ async function main() { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, }); } - + if (url.pathname === '/api/reload-years') { const playlistId = url.searchParams.get('playlist') || 'default'; const result = await trackService.reloadYearsIndex(playlistId); @@ -106,7 +110,7 @@ async function main() { }); } } - + // Audio streaming if (url.pathname.startsWith('/audio/t/')) { const token = url.pathname.split('/audio/t/')[1]; @@ -116,7 +120,7 @@ async function main() { request: { headers: new Headers(request.headers), url }, response: { headers: new Headers(), status: 200 }, }; - + if (request.method === 'HEAD') { await audioStreaming.handleHeadRequest(ctx, token); return new Response(null, { @@ -134,7 +138,7 @@ async function main() { return new Response('Not found', { status: 404 }); } } - + // Cover art if (url.pathname.startsWith('/cover/')) { const encodedFileName = url.pathname.split('/cover/')[1]; @@ -149,10 +153,10 @@ async function main() { }, }); } - } catch {} + } catch { } return new Response('Not found', { status: 404 }); } - + // Static files try { return await serveDir(request, { @@ -174,7 +178,7 @@ async function main() { // Start server logger.info(`Server starting on http://${config.host}:${config.port}`); - + await serve(handler, { hostname: config.host, port: config.port, diff --git a/src/server-deno/presentation/WebSocketServer.ts b/src/server-deno/presentation/WebSocketServer.ts index d9d87d7..7a212d5 100644 --- a/src/server-deno/presentation/WebSocketServer.ts +++ b/src/server-deno/presentation/WebSocketServer.ts @@ -17,7 +17,7 @@ export class WebSocketServer { constructor( private readonly roomService: RoomService, private readonly gameService: GameService, - ) {} + ) { } /** * Initialize Socket.IO server @@ -129,6 +129,10 @@ export class WebSocketServer { this.handleSelectPlaylist(msg, player); break; + case WS_EVENTS.SET_GOAL: + this.handleSetGoal(msg, player); + break; + case WS_EVENTS.START_GAME: await this.handleStartGame(player); break; @@ -204,13 +208,13 @@ export class WebSocketServer { } existingPlayer.setConnected(true); - + // Remove the temporary player that was created in handleConnection this.playerSockets.delete(newPlayerId); - + // Update socket mapping to point to the resumed player this.playerSockets.set(existingPlayer.id, socket); - + socket.emit('message', { type: WS_EVENTS.RESUME_RESULT, ok: true, @@ -260,7 +264,7 @@ export class WebSocketServer { try { const { room } = this.roomService.joinRoomWithPlayer(roomId, player); - + socket.emit('message', { type: 'room_joined', room: room.toSummary(), @@ -282,7 +286,7 @@ export class WebSocketServer { if (player.roomId) { const room = this.roomService.getRoom(player.roomId); this.roomService.leaveRoom(player.id); - + if (room) { this.broadcastRoomUpdate(room); } @@ -295,7 +299,7 @@ export class WebSocketServer { private handleSetName(msg: any, player: PlayerModel): void { if (msg.name) { this.roomService.setPlayerName(player.id, msg.name); - + if (player.roomId) { const room = this.roomService.getRoom(player.roomId); if (room) { @@ -331,6 +335,26 @@ export class WebSocketServer { this.broadcastRoomUpdate(room); } + /** + * Set goal (win condition) + */ + private handleSetGoal(msg: any, player: PlayerModel): void { + if (!player.roomId) return; + + const room = this.roomService.getRoom(player.roomId); + if (!room || room.hostId !== player.id) return; + + // Only allow changing goal in lobby + if (room.state.status !== 'lobby') return; + + const goal = parseInt(msg.goal, 10); + if (isNaN(goal) || goal < 1 || goal > 50) return; + + room.state.goal = goal; + logger.info(`Room ${room.id}: Goal set to ${goal} by host ${player.id}`); + this.broadcastRoomUpdate(room); + } + /** * Start game */ @@ -405,7 +429,7 @@ export class WebSocketServer { try { const result = await this.gameService.checkTitleArtistGuess(room, player.id, title, artist); - + socket.emit('message', { type: 'answer_result', ok: true, @@ -460,7 +484,7 @@ export class WebSocketServer { try { const result = await this.gameService.placeInTimeline(room, player.id, slot); - + // Store the result room.state.lastResult = { playerId: player.id, @@ -471,7 +495,7 @@ export class WebSocketServer { // Move to reveal phase - DON'T auto-skip, wait for user to click next room.state.phase = GamePhase.REVEAL; - + // Broadcast reveal with track info and result this.broadcast(room, 'reveal', { result: room.state.lastResult, @@ -531,7 +555,7 @@ export class WebSocketServer { const posSec = room.state.paused ? room.state.pausedPosSec : Math.max(0, (now - (room.state.trackStartAt || now)) / 1000); - + room.state.trackStartAt = now - Math.floor(posSec * 1000); room.state.paused = false; this.startSyncTimer(room); diff --git a/src/server-deno/public/index.html b/src/server-deno/public/index.html index aa48d9f..01ad500 100644 --- a/src/server-deno/public/index.html +++ b/src/server-deno/public/index.html @@ -130,15 +130,33 @@ @@ -159,6 +177,11 @@ Playlist: default +
+ 🏆 + Ziel: + 10 Karten +