6. The HTTP layer
6. The HTTP layer
src/routes/ → src/controllers/ → src/services/, with src/middleware/ for
validation and error formatting.
HTTP handles what is genuinely request/response. It deliberately does not seat players — that happens over the socket, because picking a lobby and taking a seat in it must be one uninterrupted step.
Endpoints
| Method | Path | Handler | Purpose |
|---|---|---|---|
POST | /api/lobbies | createLobbyHandler | Open a private lobby, get a join code |
GET | /api/lobbies | listLobbiesHandler | What is running right now |
GET | /api/lobbies/:code | getLobbyHandler | Check a code before using it |
GET | /api/leaderboard | getLeaderboardHandler | All-time table (Postgres) |
GET | /api/games | getRecentGamesHandler | Recent match history |
GET | /health | inline in app.js | Lobby counts, tick timings, memory |
src/routes/
Route tables and nothing else — no logic, so the URL structure is readable at a glance.
// lobby.routes.js
router.post('/', createLobbyHandler);
router.get('/', listLobbiesHandler);
router.get('/:code', getLobbyHandler);
Mounted in app.js as /api/lobbies and /api.
Ordering note:
GET /is registered beforeGET /:code, so the collection route is never shadowed by the parameterised one.
src/controllers/
Thin glue: parse the request, call a service or the lobby manager, send the result. Every query lives in a service so it can be tested without an HTTP request.
lobby.controller.js
summarise(game) — the one place a lobby is converted for public
consumption:
{ gameId, code, status, players, connected, maxPlayers,
remaining, collectibleCount, joinable }
It never leaks player positions, usernames or reconnect tokens. A single shared shaping function means a field can't accidentally appear in one endpoint but not another.
createLobbyHandler—createLobby(), responds201.listLobbiesHandler— everyACTIVElobby, plusgetStats()and the configured limits.getLobbyHandler—findByCode(), orthrow appError(404, 'LOBBY_NOT_FOUND').
A synchronous throw is safe here: Express 4 catches synchronous throws in route
handlers and routes them to the error middleware.
A lobby created here starts empty, which the broadcaster must not mistake for
"everyone left" — hence the playerSeq > 0 check described in
05-realtime.md. It is swept after emptyLobbyTtlMs if nobody
ever joins.
leaderboard.controller.js
Both handlers are async and use try/catch → next(err):
export async function getLeaderboardHandler(req, res, next) {
try {
res.json({ leaderboard: await getAllTimeLeaderboard({ limit: req.query.limit }) });
} catch (err) {
next(err);
}
}
Express 4 does not catch rejected promises, so an async handler that throws
without this would hang the request until it timed out. Forwarding with next(err)
means all HTTP failures are formatted in exactly one place.
src/middleware/validate.js
Everything crossing the network boundary goes through here, whether it arrived over HTTP or over a socket.
Hand-rolled rather than a schema library. With this few fields, zod would be a dependency earning its keep only in comments — but it is what you would reach for once payload shapes multiply.
validateUsername(raw)
const USERNAME_PATTERN = /^[\p{L}\p{N} _-]+$/u;
const value = raw.trim().replace(/\s+/g, ' ');
- Whitespace is collapsed so
" a b "and"a b"cannot masquerade as different players — which matters because usernames are unique per lobby. - The character set is deliberately narrow: Unicode letters and digits, space,
-,_. A username is rendered in other people's browsers, so the smaller the allowed set the better.\p{L}and\p{N}with theuflag keep non-Latin names working. - Length 2–16 from config.
Returns { ok, value } or { ok, reason, message } — a machine-readable code for
tests and clients, plus human text for display.
validateDirection(payload)
Two finite numbers, nothing else. Rejects NaN, Infinity and non-objects, which
is what stops a player position becoming NaN permanently. Magnitude is not
checked here — setInput normalises it, so this only rejects nonsense that
hints at a tampered client.
validateId(raw, { maxLength })
Non-empty trimmed string within a length cap.
src/middleware/errorHandler.js
appError(message, { status, code })
A factory, not a subclass of Error:
export function appError(message, { status = 400, code = 'BAD_REQUEST' } = {}) {
const err = new Error(message);
err.status = status;
err.code = code;
return err;
}
The only thing a class would buy is instanceof, and the handler branches on
status instead. throw appError(...) still throws a real Error, so stack
traces are unaffected — and it keeps the codebase free of classes.
notFoundHandler
Unmatched routes become clean JSON rather than Express's HTML default:
{ "error": { "code": "NOT_FOUND", "message": "No route for /api/nope" } }
errorHandler(err, req, res, next)
The single place HTTP errors become responses.
message: status >= 500 ? 'Something went wrong.' : err.message
A 500 never leaks its internal message or stack to the client — it is logged server-side instead. A 4xx message is safe because we authored it.
next must stay in the signature even though it is unused: Express identifies
error middleware by its arity of four. Dropping it silently turns this into
ordinary middleware that never runs.
Response shape
Success returns the resource directly. Failure is always:
{ "error": { "code": "MACHINE_READABLE", "message": "Human readable." } }
Socket errors mirror this with { ok: false, reason: 'CODE' }, so both transports
speak the same vocabulary — LOBBY_NOT_FOUND, LOBBY_FULL, USERNAME_TAKEN mean
the same thing whether they arrive over HTTP or a websocket.