egg-catcher-api / server /server.js
Abhinay
Initial commit — egg-catcher arcade
024703b
Raw
History Blame Contribute Delete
8.89 kB
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);
});