`express` to ^5.2.1 and `@types/express` to ^5.0.6.
Little of express 5's breaking surface is reachable from here. The only
routes are literal — `/health` and `/validate` — plus bare `app.use`, so
nothing meets path-to-regexp v8. `req.query` is never read: the mismatcher
parses the query string itself through `qs`, so the change of default query
parser does not apply. No `app.del`, no `req.param()`, no `res.send(status)`.
express 5 does change how a server reports trouble, in two directions, and
`lib/src/express-listen.ts` now holds both for the three places that start
one.
`app.listen` adds its callback as the server's `error` listener as well as
handing it to node's `listening`, so a port that cannot be bound arrives as
the argument. Passing `resolve` straight in settles the promise
successfully with the error as its value, reporting a server that is not
accepting requests as started. Express 4 never called back on a bind
failure, which is why this was sound before. Confirmed by running two
servers on one port against each major: the callback fires with EADDRINUSE
on 5 and does not fire at all on 4.
That listener is then spent, but stays attached, so an error raised after
the bind is delivered to a no-op and disappears. On express 4 the same
error was unhandled and terminated the process. Confirmed the same way:
after `listening`, express 4 leaves no `error` listener and `emit` throws,
express 5 leaves one and `emit` returns quietly. Dropping the spent
listener restores the earlier behaviour exactly.
`spot docs` was the third caller and had neither fix: it awaited
`server.listen(port)`, which returns a server rather than a promise, so the
`catch` could not run and both success lines printed before the bind was
attempted. A busy port now exits through oclif with `Code: EADDRINUSE` and
prints no success line at all.
Each of the three properties is covered, because the compiler covers none
of them: a callback that ignores its argument, one that always rejects, and
one that leaves the spent listener attached all type-check. Mutation-checked
all three — every one produces no type error and fails a case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ticket
COMPASS-28 — Bump
spot's NodeJS minimum from 18 to 22What
express^4.19.2 → ^5.2.1,@types/express^4.17.21 → ^5.0.6.Most of express 5's breaking surface is not reachable here
Audited before bumping:
/healthand/validate, plus bareapp.useextended→simplereq.queryis never read; the mismatcher parses the query string itself viaqsres.send(status)removedres.send(object)app.del,req.param()removedThe types caught a real behaviour change
Both servers built their readiness promise like this:
express 5's
app.listendoesserver.once('error', done)on the callback it is handed:So a port that cannot be bound now arrives at that callback. Passing
resolvestraight in settles the promise successfully, with the error as its resolved value — reporting a server that is not accepting requests as started, and lettingdefer()'s caller proceed.Express 4 had no such line and never called back on a bind failure, which is why the old code was sound then and is not now. Confirmed empirically against both majors by starting two servers on one port:
EADDRINUSEBoth call sites now reject instead:
Why each fix carries a test
The compiler does not cover this. A callback written to ignore its argument type-checks cleanly and resolves anyway:
So
validation-server/server.spec.tsandmock-server/server.spec.tseach occupy the port first and assert the rejection. Mutation-checked both — the snippet above produces 0 type errors and fails both tests.How this was verified
Locally on Node 22.23.2, all exit 0:
pnpm buildpnpm testpnpm lint:checkpnpm build-docsdocker build+check-image-parity/healthcasesAgainst the real CLI, since the validation server is one of the commands the image ships:
spot validation-serverprints the readiness line and answers/healthwith 200 on express 5.Error: listen EADDRINUSE/Code: EADDRINUSE— the fix working end to end, rather than reporting a false start.Unrelated pre-existing behaviour, noted so it is not read as a regression:
POST /validatewith a malformed body ({}) returns HTTP 500 withCannot read properties of undefined (reading 'path'). Byte-identical on express 4.19.2, so this bump does not touch it. Arguably it should be a 400 rather than a 500 leaking an internalTypeError— worth its own ticket.