Our WebSocket server is also a three-route HTTP server, and the only open route is the one that counts the phones in the room
PubTrivia deploys two processes from one repository: the Next.js app, and a long-running Node process that holds the WebSocket connections. The second one is interesting in a way I did not expect when I wrote it, because
PubTrivia deploys two processes from one repository: the Next.js app, and a long-running Node process that holds the WebSocket connections. The second one is interesting in a way I did not expect when I wrote it, because a WebSocket server still has to be an HTTP server, and what you choose to put on those HTTP routes is a privacy decision in disguise.
It has exactly three, plus a catch-all:
const server = createServer((req, res) => {
if (method === 'GET' && url === '/health') return handleHealth(req, res)
if (method === 'GET' && url === '/internal/stats') return handleStats(req, res)
if (method === 'POST' && url === '/internal/notify') return handleNotify(req, res)
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
One of those three is open to the world. The other two need a shared secret. The WebSocket upgrade is handled separately, and refuses any origin that is not on a configured list, which is a story someone else has probably written.
The open one
/health exists because a managed host needs to know whether to restart the container. It could have returned the string ok and nothing else. It returns this:
$ curl -s https://ws-server-production-f275.up.railway.app/health
{"status":"ok","sessions":0,"clients":0,"redis":true}
Three facts beyond liveness: how many quiz sessions this instance is holding, how many sockets are attached to them, and whether cross-instance fan-out through Redis is switched on. A quiet Thursday morning, so both counts are zero.
That is deliberately a small amount of information. sessions and clients are cardinalities. They tell you the product is in use, which is a thing our own marketing page says anyway, and they let a human glance at a dashboard during a quiz night and see phones arriving. There is no identifier in there, and nothing a competitor could not estimate by signing up.
The closed one, and the sentence that decided it
/internal/stats is the same numbers plus process uptime, resident memory, how many game-clock timers are armed by kind, and then the thing that changed its access control:
"perSession": [{ "sessionId": "...", "hosts": 1, "players": 23 }]
The handler's own comment is the argument for the 401:
Session ids are not secret to players, but the list of every quiz running right now is, so unlike /health this takes the internal secret.
Each individual session id is known to everyone in that pub: it is in the URL on their phones. The enumeration is different in kind. A list of every live session, with the number of people in each, is a map of where this product is being used tonight, assembled from facts that are each individually harmless. Aggregation is a privacy property in its own right, and the place to enforce it is the endpoint that does the aggregating.
So:
$ curl -s -w '%{http_code}\n' https://ws-server-production-f275.up.railway.app/internal/stats
{"error":"Unauthorized"}401
$ curl -s -w '%{http_code}\n' -H 'x-internal-secret: nope' .../internal/stats
{"error":"Unauthorized"}401
$ curl -s -w '%{http_code}\n' .../anything-else
{"error":"Not found"}404
The check itself is six lines, and the only thing worth noting about it is that the length comparison comes before the constant-time one, because timingSafeEqual throws on mismatched lengths rather than returning false:
export function secretMatches(given) {
const expected = process.env.WS_INTERNAL_SECRET
if (typeof given !== 'string' || !expected) return false
const a = Buffer.from(given)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
A missing secret in the environment means every internal request is refused, which is the right direction for that failure to point. The process also refuses to boot without it: five required environment variables are validated at startup, the port among them, and a missing one exits the process with a message naming it rather than starting a server that accepts connections and then cannot do anything with them.
The numbers that cannot be added up
Here is the honest limitation, written into the function that produces them:
Only this instance's sockets: with Redis fan-out across several instances, each one reports its own share.
A socket belongs to the process that is holding it. If three instances are running, /health on each reports a third of the truth, and perSession on each lists only the sessions with a socket on that instance. Summing them would require asking every instance, which means service discovery, which means a much bigger thing than an admin panel needs.
So the stats are documented as per-instance rather than aggregated, and the consumer is written to tolerate that. This is the generally useful part: an observability endpoint that quietly implies it is global is worse than one that says what it actually sees. The number of pages I have seen that add up per-pod counters and present the total as a fact is not small.
Why a failed probe is null and not zero
The admin side reads both endpoints. Every probe has a four second timeout and returns failure as data rather than throwing, because the point of a status panel is to show what is down, and one dead dependency must not take the page with it:
const res = await fetch(`${base}/internal/stats`, {
cache: 'no-store',
headers: { 'x-internal-secret': secret },
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
})
if (!res.ok) return null
null, not an empty object, and the distinction is load-bearing. The panel judges each live quiz, and one of the things it looks at is whether anybody is actually connected to it. There are four ways the stats call can come back useless: the URL is not configured, the secret is not configured, the server is unreachable, or the server is an older deploy from before the endpoint existed, which answers its own 404. In all four, "I could not ask" has to be distinguishable from "nobody is there", because the second one is a finding and the first one is not.
That gives two layers:
/** Connections for one session, or null when the stats are unavailable. */
export function connectionsFor(stats, sessionId) {
if (!stats) return null
return stats.perSession.get(sessionId) ?? { hosts: 0, players: 0 }
}
If the stats are missing, the answer is null and the checker skips the connection tests entirely. If the stats arrived and this session is simply not in them, the answer is a real zero, because the server told us about every session it is holding and this was not one of them. Same shape of output, completely different epistemic status, and the type is what keeps them apart: the field is { hosts: number; players: number } | null all the way down to the function that renders the badge.
The alternative, defaulting a failed fetch to zeroes, produces a panel that lights up every quiz in the country as abandoned the moment one environment variable goes missing. I know this because that is what the first version did.
Presence, never values
One last piece of the same panel, which is the cheapest useful thing in it. A list of configuration checks that reports whether a secret exists, never what it is, and names the symptom rather than the setting:
- missing webhook secret: "subscriptions never activate"
- missing mail key: "invites and the mailing list cannot send"
- missing link to this WebSocket server: "phones never hear about launches"
Writing the consequence next to the check is a two-minute change that turns a list of variable names into a list of things that are currently broken. It is the difference between an operator reading "not configured" and an operator knowing why nobody's quiz is starting.
Check it yourself
The liveness endpoint is genuinely open, so you can run the whole demonstration from a terminal:
$ H=https://ws-server-production-f275.up.railway.app
$ curl -s $H/health
{"status":"ok","sessions":0,"clients":0,"redis":true}
$ curl -s -o /dev/null -w '%{http_code}\n' $H/internal/stats
401
$ curl -s -o /dev/null -w '%{http_code}\n' $H/
404
If you run it during a British weekday evening the two counters will not be zero, which is the only live telemetry on this product anybody outside it can see. The thing those sockets are carrying is described, in prose rather than JSON, on the live leaderboard page, and the screen they feed is the big one.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.