Spaces:
Running
Running
| import 'dotenv/config'; | |
| import express from 'express'; | |
| import cors from 'cors'; | |
| import { networkInterfaces } from 'os'; | |
| import { fileURLToPath } from 'url'; | |
| import { dirname, join } from 'path'; | |
| import { existsSync } from 'fs'; | |
| import { MongoClient } from 'mongodb'; | |
| const { | |
| MONGO_URI, | |
| MONGO_DB = 'news', | |
| MONGO_COLLECTION = 'egg_catcher_scores', | |
| } = process.env; | |
| const PORT = process.env.PORT || process.env.API_PORT || 5174; | |
| const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| const DIST_DIR = join(__dirname, '..', 'dist'); | |
| const SERVE_STATIC = existsSync(DIST_DIR); | |
| if (!MONGO_URI) { | |
| console.error('[egg-catcher] MONGO_URI is missing from .env'); | |
| process.exit(1); | |
| } | |
| const mongo = new MongoClient(MONGO_URI, { serverSelectionTimeoutMS: 8000 }); | |
| let scores; | |
| async function connectMongo() { | |
| await mongo.connect(); | |
| const db = mongo.db(MONGO_DB); | |
| scores = db.collection(MONGO_COLLECTION); | |
| await scores.createIndex({ username_lower: 1 }, { unique: true }); | |
| await scores.createIndex({ best: -1 }); | |
| console.log(`[egg-catcher] mongo connected — ${MONGO_DB}.${MONGO_COLLECTION}`); | |
| } | |
| const app = express(); | |
| app.use(cors()); | |
| app.use(express.json({ limit: '32kb' })); | |
| /* ------- helpers ------- */ | |
| const USERNAME_RE = /^[A-Za-z0-9_\-]{2,16}$/; | |
| function cleanUsername(raw) { | |
| return String(raw || '').trim(); | |
| } | |
| /* ------- leaderboard endpoints ------- */ | |
| app.post('/api/user/check', async (req, res) => { | |
| const username = cleanUsername(req.body?.username); | |
| if (!USERNAME_RE.test(username)) { | |
| return res.status(400).json({ ok: false, error: 'Username must be 2-16 letters, numbers, _ or -' }); | |
| } | |
| const existing = await scores.findOne({ username_lower: username.toLowerCase() }); | |
| res.json({ ok: true, exists: !!existing, best: existing?.best || 0 }); | |
| }); | |
| app.post('/api/score', async (req, res) => { | |
| const username = cleanUsername(req.body?.username); | |
| const score = Math.max(0, Math.min(1_000_000, Number(req.body?.score) || 0)); | |
| if (!USERNAME_RE.test(username)) { | |
| return res.status(400).json({ ok: false, error: 'invalid username' }); | |
| } | |
| const key = username.toLowerCase(); | |
| const now = new Date(); | |
| const existing = await scores.findOne({ username_lower: key }); | |
| if (!existing) { | |
| await scores.insertOne({ | |
| username, | |
| username_lower: key, | |
| best: score, | |
| lastScore: score, | |
| games: 1, | |
| createdAt: now, | |
| updatedAt: now, | |
| }); | |
| } else { | |
| await scores.updateOne( | |
| { username_lower: key }, | |
| { | |
| $set: { | |
| username, | |
| lastScore: score, | |
| updatedAt: now, | |
| ...(score > (existing.best || 0) ? { best: score } : {}), | |
| }, | |
| $inc: { games: 1 }, | |
| }, | |
| ); | |
| } | |
| const updated = await scores.findOne({ username_lower: key }); | |
| const rank = (await scores.countDocuments({ best: { $gt: updated.best } })) + 1; | |
| const total = await scores.countDocuments({}); | |
| res.json({ ok: true, rank, total, best: updated.best, lastScore: updated.lastScore }); | |
| }); | |
| app.get('/api/leaderboard', async (req, res) => { | |
| const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25)); | |
| const username = cleanUsername(req.query.username); | |
| const top = await scores | |
| .find({}, { projection: { _id: 0, username: 1, best: 1, games: 1, updatedAt: 1 } }) | |
| .sort({ best: -1, updatedAt: 1 }) | |
| .limit(limit) | |
| .toArray(); | |
| let me = null; | |
| if (USERNAME_RE.test(username)) { | |
| const entry = await scores.findOne({ username_lower: username.toLowerCase() }); | |
| if (entry) { | |
| const rank = (await scores.countDocuments({ best: { $gt: entry.best } })) + 1; | |
| me = { | |
| username: entry.username, | |
| best: entry.best, | |
| games: entry.games, | |
| rank, | |
| }; | |
| } | |
| } | |
| const total = await scores.countDocuments({}); | |
| res.json({ ok: true, top, total, me }); | |
| }); | |
| app.get('/api/health', async (_req, res) => { | |
| try { | |
| const count = await scores.estimatedDocumentCount(); | |
| res.json({ ok: true, mongo: 'up', count }); | |
| } catch (err) { | |
| res.status(500).json({ ok: false, error: String(err) }); | |
| } | |
| }); | |
| /* ============================================================ | |
| GAME ROOMS — SSE relay between laptop and phone controller | |
| ============================================================ */ | |
| const ROOM_TTL_MS = 1000 * 60 * 30; | |
| const rooms = new Map(); | |
| function newRoomId() { | |
| const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; | |
| let id = ''; | |
| for (let i = 0; i < 6; i++) id += chars[Math.floor(Math.random() * chars.length)]; | |
| return id; | |
| } | |
| function pruneRooms() { | |
| const now = Date.now(); | |
| for (const [id, room] of rooms) { | |
| if (now - room.createdAt > ROOM_TTL_MS && !room.game && room.controllers.size === 0) { | |
| rooms.delete(id); | |
| } | |
| } | |
| } | |
| setInterval(pruneRooms, 60_000).unref?.(); | |
| function getLocalIps() { | |
| const nets = networkInterfaces(); | |
| const out = []; | |
| for (const name of Object.keys(nets)) { | |
| for (const net of nets[name] || []) { | |
| if (net.family === 'IPv4' && !net.internal) out.push({ name, address: net.address }); | |
| } | |
| } | |
| const isVirtual = (n) => /vethernet|hyper-v|wsl|virtualbox|vmware|loopback|docker|local area connection\*/i.test(n); | |
| const isWifi = (n) => /wi-?fi|wlan|wireless/i.test(n); | |
| const isLan = (a) => /^(192\.168|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a); | |
| const score = (c) => { | |
| let s = 0; | |
| if (isVirtual(c.name)) s += 100; | |
| if (!isLan(c.address)) s += 10; | |
| if (isWifi(c.name)) s -= 5; | |
| return s; | |
| }; | |
| out.sort((a, b) => score(a) - score(b)); | |
| return out; | |
| } | |
| app.get('/api/game/local-ip', (_req, res) => { | |
| const ips = getLocalIps(); | |
| res.json({ ip: ips[0]?.address || 'localhost', candidates: ips }); | |
| }); | |
| app.post('/api/game/room', (_req, res) => { | |
| const id = newRoomId(); | |
| rooms.set(id, { game: null, controllers: new Set(), createdAt: Date.now(), username: null }); | |
| res.json({ roomId: id }); | |
| }); | |
| function sseHeaders(res) { | |
| res.set({ | |
| 'Content-Type': 'text/event-stream', | |
| 'Cache-Control': 'no-cache, no-transform', | |
| Connection: 'keep-alive', | |
| 'X-Accel-Buffering': 'no', | |
| }); | |
| res.flushHeaders?.(); | |
| } | |
| app.get('/api/game/events/:roomId', (req, res) => { | |
| const { roomId } = req.params; | |
| let room = rooms.get(roomId); | |
| if (!room) { | |
| room = { game: null, controllers: new Set(), createdAt: Date.now(), username: null }; | |
| rooms.set(roomId, room); | |
| } | |
| sseHeaders(res); | |
| room.game = res; | |
| res.write(`event: ready\ndata: ${JSON.stringify({ roomId, paired: room.controllers.size > 0 })}\n\n`); | |
| for (const c of room.controllers) c.write(`event: paired\ndata: {}\n\n`); | |
| const ping = setInterval(() => { | |
| try { res.write(`: ping\n\n`); } catch {} | |
| }, 15_000); | |
| req.on('close', () => { | |
| clearInterval(ping); | |
| if (room.game === res) room.game = null; | |
| }); | |
| }); | |
| app.get('/api/game/controller-events/:roomId', (req, res) => { | |
| const { roomId } = req.params; | |
| const room = rooms.get(roomId); | |
| if (!room) return res.status(404).end(); | |
| sseHeaders(res); | |
| room.controllers.add(res); | |
| res.write(`event: ready\ndata: {"paired":${room.game ? 'true' : 'false'}}\n\n`); | |
| const ping = setInterval(() => { | |
| try { res.write(`: ping\n\n`); } catch {} | |
| }, 15_000); | |
| req.on('close', () => { | |
| clearInterval(ping); | |
| room.controllers.delete(res); | |
| }); | |
| }); | |
| app.post('/api/game/control', (req, res) => { | |
| const { roomId, action } = req.body || {}; | |
| const room = rooms.get(roomId); | |
| if (!room) return res.status(404).json({ error: 'room not found' }); | |
| if (!room.game) return res.status(409).json({ error: 'game not connected' }); | |
| const safe = String(action || '').slice(0, 32); | |
| try { | |
| room.game.write(`event: control\ndata: ${JSON.stringify({ action: safe })}\n\n`); | |
| } catch {} | |
| res.json({ ok: true }); | |
| }); | |
| app.post('/api/game/state', (req, res) => { | |
| const { roomId, ...state } = req.body || {}; | |
| const room = rooms.get(roomId); | |
| if (!room) return res.status(404).json({ error: 'room not found' }); | |
| const payload = JSON.stringify(state).slice(0, 512); | |
| for (const c of room.controllers) { | |
| try { c.write(`event: state\ndata: ${payload}\n\n`); } catch {} | |
| } | |
| res.json({ ok: true }); | |
| }); | |
| /* ------- start ------- */ | |
| // ------- static frontend (production) ------- | |
| if (SERVE_STATIC) { | |
| app.use(express.static(DIST_DIR)); | |
| app.get(/^\/(?!api\/).*/, (_req, res) => { | |
| res.sendFile(join(DIST_DIR, 'index.html')); | |
| }); | |
| console.log(`[egg-catcher] serving static frontend from ${DIST_DIR}`); | |
| } | |
| connectMongo() | |
| .then(() => { | |
| app.listen(PORT, '0.0.0.0', () => { | |
| console.log(`[egg-catcher] listening on http://0.0.0.0:${PORT}`); | |
| if (!SERVE_STATIC) { | |
| console.log(`[egg-catcher] LAN ips: ${getLocalIps().map((c) => `${c.address} (${c.name})`).join(', ')}`); | |
| } | |
| }); | |
| }) | |
| .catch((err) => { | |
| console.error('[egg-catcher] mongo connect failed:', err.message); | |
| process.exit(1); | |
| }); | |