src/http/app.ts — the web endpoints
src/http/app.ts — the web endpoints
In one sentence: the ordinary web addresses — health checks, metrics, lobby lists, leaderboards — plus serving the game page itself.
Size: 113 lines. Depends on: Express, and the pieces it reports on.
Why this file exists
Gameplay runs over WebSocket, so these endpoints are not in the hot path. They exist for three separate audiences:
| Audience | Endpoint | Why |
|---|---|---|
| Load balancers, Docker | /health | is this process alive? |
| Me, when measuring | /metrics | is the server keeping up? |
| The browser | /api/*, static files | lobby list, leaderboards, the game page |
/metrics is the most valuable of these. It is what the stress test reads to
decide whether the server is coping, and it is where the sim-versus-broadcast
split lives — the measurement that found the real bottleneck.
The code, line by line
1. Dependencies (lines 9–18)
export interface AppDependencies {
manager: GameManager;
loop: GameLoop;
sockets: SocketServer;
repository: GameRepository;
writeQueue: WriteQueue;
config: GameConfig;
persistenceEnabled: boolean;
}
Everything is handed in rather than imported and constructed here. This is
dependency injection: the file describes what it needs, and server.ts decides
what to supply.
That is what lets a test build a whole app with an in-memory repository.
2. Setup (lines 26–29)
export function createApp(deps: AppDependencies): Express {
const app = express();
app.use(express.json({ limit: '16kb' }));
app.disable('x-powered-by');
express.json({ limit: '16kb' })— parse JSON request bodies, but reject anything over 16 KB. Without a limit, someone could send a 500 MB body and exhaust memory. None of our endpoints need a body at all, so 16 KB is generous.app.disable('x-powered-by')— Express normally announces itself in a response header. Telling attackers what you run makes their job easier, for no benefit.
3. /health (lines 31–33)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) });
});
The simplest possible "am I alive?" check. Deliberately does not touch the database — if Postgres is down the game still works, so health should not report failure.
_req — the underscore is a convention meaning "this parameter exists but is
unused". It stops linters complaining.
4. /metrics — the important one (lines 39–57)
app.get('/metrics', (_req, res) => {
const mem = process.memoryUsage();
res.json({
loop: deps.loop.getMetrics(),
game: deps.manager.stats(),
sockets: deps.sockets.metrics,
persistence: {
enabled: deps.persistenceEnabled,
queueDepth: deps.writeQueue.depth,
inFlight: deps.writeQueue.active,
...deps.writeQueue.stats,
},
process: {
rssMb: +(mem.rss / 1024 / 1024).toFixed(1),
heapUsedMb: +(mem.heapUsed / 1024 / 1024).toFixed(1),
uptimeSec: Math.round(process.uptime()),
},
});
});
Four groups of numbers, in order of usefulness:
loop — the health of the game. avgTickMs against budgetMs is the number
that matters; everything else explains why it moved.
game — how many rooms and players exist.
sockets — connections, joins, and the two abuse counters
(inputsRateLimited, inputsInvalid).
persistence — is the database connected, how deep is the write queue, how
many writes failed or were dropped.
process — memory and uptime.
+(mem.rss / 1024 / 1024).toFixed(1) reads oddly, so unpack it:
mem.rssis bytes, e.g.189792256./ 1024 / 1024converts to megabytes:181.0.….toFixed(1)rounds to one decimal — but returns a string,"181.0".- The leading
+converts it back to a number,181.
Without the +, the JSON would contain "181.0" in quotes, and anything charting
it would have to parse it.
...deps.writeQueue.stats spreads the queue's counters in alongside the two
live values.
5. /api/rooms (lines 64–71)
app.get('/api/rooms', (req, res) => {
const includeFinished = req.query.all === '1';
const rooms = deps.manager
.listRooms()
.filter((room) => includeFinished || room.status !== 'finished')
.map((room) => room.getSummary());
res.json({ rooms, count: rooms.length });
});
Reads straight from memory. No database query — the list of live lobbies is not durable data.
?all=1 includes finished-but-not-yet-deleted rooms. That flag was added for a
real reason: the stress test audits every lobby's scores, and without it the audit
ran after all the games had finished, found zero rooms, and silently passed
having checked nothing. A check that passes vacuously is worse than no check.
6. /api/rooms/:roomId (lines 73–80)
app.get('/api/rooms/:roomId', (req, res) => {
const room = deps.manager.getRoom(String(req.params.roomId).toUpperCase());
if (!room) {
res.status(404).json({ error: 'room_not_found' });
return;
}
res.json({ ...room.getSummary(), leaderboard: room.getLeaderboard() });
});
:roomId is a URL parameter — /api/rooms/WDZYAP puts WDZYAP in
req.params.roomId.
.toUpperCase() so a lowercase URL still works.
The browser uses this to clean up stale sessions. On page load it asks about
each remembered lobby; a 404 or status: 'finished' means that saved session is
dead and gets removed. That fixed a real bug where a player who left mid-game was
offered a "rejoin" into a lobby that had since ended.
7. The database-backed endpoints (lines 83–103)
app.get('/api/leaderboard', async (req, res) => {
const limit = clampLimit(req.query.limit, 20, 100);
try {
res.json({ players: await deps.repository.getTopPlayers(limit) });
} catch (err) {
console.error('[api] leaderboard failed', err);
res.status(503).json({ error: 'persistence_unavailable' });
}
});
The only two endpoints that touch storage, and both are wrapped in try/catch.
503 means "service unavailable" — the right code, because the failure is
temporary and the client may reasonably retry. A 500 would suggest a bug.
Note this is the only place await appears in the HTTP layer, and it is
nowhere near the game loop.
8. Serving the game page (line 105)
app.use(express.static(path.join(__dirname, '..', '..', 'public')));
Serves everything in public/, so visiting / returns index.html.
The path walks up two folders. That works in both modes because of how the
build is configured: in development __dirname is src/http, and after building
it is dist/http — two levels up from either lands at the project root.
Registered last on purpose. Express tries routes in order, so all the /api
routes get first refusal and only unmatched paths fall through to static files.
9. clampLimit (lines 109–113)
function clampLimit(raw: unknown, fallback: number, max: number): number {
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.floor(parsed), max);
}
Query parameters arrive as text and are entirely under the caller's control.
| Request | Result | Why |
|---|---|---|
?limit=50 | 50 | fine |
?limit=abc | fallback | Number("abc") is NaN |
?limit=-5 | fallback | negative rejected |
?limit=999999 | max | capped |
| no limit | fallback | Number(undefined) is NaN |
The cap is the point. Without it, ?limit=10000000 would ask PostgreSQL for ten
million rows, serialise them all to JSON, and take the server down. One small
function removes that entire class of problem.
Math.floor because ?limit=5.7 should be 5, not a fractional LIMIT.
Endpoint summary
| Endpoint | Reads from | Notes |
|---|---|---|
GET /health | nothing | liveness only |
GET /metrics | memory | tick budget, drift, sockets, queue |
GET /api/rooms | memory | ?all=1 includes finished |
GET /api/rooms/:id | memory | 404 when gone |
GET /api/leaderboard | PostgreSQL | ?limit, capped at 100 |
GET /api/sessions | PostgreSQL | ?limit, capped at 50 |
| everything else | public/ | the game page |
If an interviewer asks
"How would you know if the server were struggling?"
"
/metrics. Specificallyloop.avgTickMsagainstloop.budgetMs, andoverrunTicks. At 2,000 players it used 13 ms of a 50 ms budget with zero overruns. The sim-versus-broadcast split in there is what told me the bottleneck was sending, not simulating."
"Why doesn't
/healthcheck the database?""Because the game works without it. If health failed when Postgres was down, an orchestrator would restart a perfectly healthy server. Database status is reported separately under
persistence.enabled."
"What stops someone requesting a million rows?"
"
clampLimitcaps it — 100 for the leaderboard, 50 for sessions — and falls back to a default for anything non-numeric or negative."