feat: production deployment — Dockerfile, docker-compose, SPA serving, classic bot fights
- Add Dockerfile (multi-stage: build frontend + server, serve from single container) - Add docker-compose.yml for Portainer stack deployment - Server serves frontend SPA in production (static assets + SPA fallback) - Auto-run migrations and seed mock bots on server startup - DB path configurable via DB_PATH env var - Add "Fight a Classic Bot" button for instant mock bot matches - FIGHT button queues for real AI opponents Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
120250c01a
commit
6bf9fe27b3
@@ -7,6 +7,10 @@ import { queueRouter } from './routes/queue.js'
|
||||
import { authRouter } from './routes/auth.js'
|
||||
import { docsRouter } from './routes/docs.js'
|
||||
import { rateLimit } from './middleware/rate-limit.js'
|
||||
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { cleanupOrphanedFights } from './engine/orchestrator.js'
|
||||
|
||||
export const app = new Hono()
|
||||
@@ -30,6 +34,57 @@ app.route('/api/fights', fightsRouter)
|
||||
app.route('/api/queue', queueRouter)
|
||||
app.route('/api/docs', docsRouter)
|
||||
|
||||
// In production, serve the frontend SPA
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = join(__dirname, '..', 'public')
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
|
||||
const MIME: Record<string, string> = {
|
||||
js: 'application/javascript',
|
||||
css: 'text/css',
|
||||
html: 'text/html',
|
||||
json: 'application/json',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
webp: 'image/webp',
|
||||
webmanifest: 'application/manifest+json',
|
||||
}
|
||||
|
||||
function serveFile(c: any, reqPath: string, cacheControl: string) {
|
||||
const resolved = join(publicDir, reqPath)
|
||||
// Prevent path traversal
|
||||
if (!resolved.startsWith(publicDir)) return c.notFound()
|
||||
if (!existsSync(resolved)) return c.notFound()
|
||||
const ext = resolved.split('.').pop() || ''
|
||||
c.header('Content-Type', MIME[ext] || 'application/octet-stream')
|
||||
c.header('Cache-Control', cacheControl)
|
||||
return c.body(readFileSync(resolved))
|
||||
}
|
||||
|
||||
// Hashed assets — immutable cache
|
||||
app.get('/assets/*', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable'))
|
||||
|
||||
// Root static files (favicon, manifest, robots, etc.)
|
||||
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
|
||||
app.get('/robots.txt', (c) => serveFile(c, '/robots.txt', 'public, max-age=86400'))
|
||||
app.get('/manifest.webmanifest', (c) => serveFile(c, '/manifest.webmanifest', 'public, max-age=86400'))
|
||||
|
||||
// SPA fallback: all non-API routes serve index.html
|
||||
app.get('*', (c) => {
|
||||
if (c.req.path.startsWith('/api/')) return c.notFound()
|
||||
const indexPath = join(publicDir, 'index.html')
|
||||
c.header('Content-Type', 'text/html')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
return c.body(readFileSync(indexPath))
|
||||
})
|
||||
|
||||
console.log('[botfights] serving frontend from', publicDir)
|
||||
}
|
||||
|
||||
// Cleanup orphaned fights on startup
|
||||
cleanupOrphanedFights().then(() => {
|
||||
console.log('[botfights] orphaned fights cleaned up')
|
||||
|
||||
Reference in New Issue
Block a user