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:
Dorian
2026-03-07 10:42:18 +00:00
co-authored by Claude Opus 4.6
parent 120250c01a
commit 6bf9fe27b3
17 changed files with 2321 additions and 298 deletions
+55
View File
@@ -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')
+3 -3
View File
@@ -6,10 +6,10 @@ import { fileURLToPath } from 'url'
import { mkdirSync } from 'fs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const dataDir = join(__dirname, '..', '..', 'data')
mkdirSync(dataDir, { recursive: true })
const dbPath = process.env.DB_PATH || join(__dirname, '..', '..', 'data', 'botfights.db')
mkdirSync(dirname(dbPath), { recursive: true })
const sqlite = new Database(join(dataDir, 'botfights.db'))
const sqlite = new Database(dbPath)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
+76
View File
@@ -0,0 +1,76 @@
import { sqlite } from './index.js'
export function runMigrations() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS bots (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
webhook_url TEXT NOT NULL,
avatar_seed TEXT NOT NULL,
archetype TEXT NOT NULL DEFAULT 'standard',
secret_hash TEXT NOT NULL,
public_key TEXT,
profile_pic_url TEXT,
elo_rating REAL NOT NULL DEFAULT 1200,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
win_streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
tier INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_fight_at TEXT,
consecutive_errors INTEGER NOT NULL DEFAULT 0,
last_error_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS fights (
id TEXT PRIMARY KEY,
bot_a_id TEXT NOT NULL REFERENCES bots(id),
bot_b_id TEXT NOT NULL REFERENCES bots(id),
arena TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled',
winner_id TEXT REFERENCES bots(id),
bot_a_hp INTEGER NOT NULL DEFAULT 200,
bot_b_hp INTEGER NOT NULL DEFAULT 200,
total_rounds INTEGER NOT NULL DEFAULT 0,
scheduled_at TEXT,
started_at TEXT,
ended_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rounds (
id TEXT PRIMARY KEY,
fight_id TEXT NOT NULL REFERENCES fights(id),
round_number INTEGER NOT NULL,
challenge_type TEXT NOT NULL,
challenge_data TEXT NOT NULL,
bot_a_response TEXT,
bot_a_time_ms INTEGER,
bot_a_score REAL,
bot_b_response TEXT,
bot_b_time_ms INTEGER,
bot_b_score REAL,
winner_id TEXT REFERENCES bots(id),
narration TEXT,
created_at TEXT NOT NULL
);
`)
// Column migrations for existing databases
const migrations = [
"ALTER TABLE bots ADD COLUMN archetype TEXT NOT NULL DEFAULT 'standard'",
"ALTER TABLE bots ADD COLUMN profile_pic_url TEXT",
"ALTER TABLE bots ADD COLUMN last_fight_at TEXT",
"ALTER TABLE bots ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bots ADD COLUMN last_error_at TEXT",
"ALTER TABLE bots ADD COLUMN customization TEXT",
]
for (const sql of migrations) {
try { sqlite.exec(sql) } catch { /* column already exists */ }
}
console.log('[botfights] database migrated')
}
+6
View File
@@ -1,5 +1,11 @@
import { serve } from '@hono/node-server'
import { app } from './app.js'
import { runMigrations } from './db/startup.js'
import { seedMockBots } from './engine/mock.js'
// Run migrations and seed mock bots before starting the server
runMigrations()
await seedMockBots()
const port = Number(process.env.PORT) || 9100