I11 · Part 2 · How it runs
Every endpoint, and how each one fails
Three route handlers run per request, and three families of JSON routes were run once, at build time. This chapter covers what each takes, what it caches and what the reader sees when it breaks.
06On this page
The whole list
The site has 26 route.ts files. Three live under app/api/ and run as
functions, because each one reads something that only exists per request: a header, a query
string or a body. The other 23 export dynamic = "force-static". The build calls them
and writes their responses to disk, so in production they behave like any other file.
| Route | Kind | Called by | If it fails |
|---|---|---|---|
/api/tts | Function | The Listen button | The narrator does not start; the chapter reads normally |
/api/joke | Function | The card on /progress | A hardcoded joke, or a skeleton line |
/api/client-error | Function | ErrorReporter | The error is not logged |
/problems/{id}/cases | Static, 538 files | The playground | Only JavaScript is graded |
/mock/bank/{stage} | Static, 12 files | The mock lobby | The loop cannot start, and the lobby says why |
{topic}/search-index.json | Static, 21 files | The chapter search box | Search matches titles and subtitles only |
None of them is required to read a page. That is the property this chapter checks for each endpoint in turn.
What the three share
Each one sets runtime = "nodejs" and starts by calling
overRateLimit(req, name, max) from lib/rateLimit.ts. The limiter is a
Map in module scope, keyed by the route name and the first address in
x-forwarded-for. It uses a fixed 60-second window, and when it tracks more than 5,000
keys it sweeps out the expired ones. It is simple and it has the obvious weakness: each
serverless instance counts on its own, so the real ceiling is the limit multiplied by however many
instances are warm. The Vercel Firewall rule in front of /api/ is the actual
defence. The in-process limit is a second layer that fails differently.
robots.ts also disallows /api/, so crawlers stay out.
/api/tts
This is the only endpoint doing real work. Inputs: text, trimmed and at most
2,000 characters. voice, which must be one of the 11 values in
lib/edge-voices.ts. rate, clamped to 0.5 to 2 with a default of 1. And
pitch, which must match +NN%, -NN% or 0%. The
text is escaped for SSML, then msedge-tts opens a WebSocket to Microsoft's
read-aloud service and asks for 24 kHz, 48 kbit/s mono MP3 with word-boundary events.
Output: the MP3 as the response body, with the word timings packed into one header.
X-Word-Timings is base64 of a JSON array of [offsetMs, durationMs, word]
tuples, converted from the service's 100-nanosecond ticks. narration.ts turns the body
into an object URL, decodes the header, and uses the timings to highlight the word being spoken
with the CSS Custom Highlight API where the browser has it.
Caching: public, max-age=3600, s-maxage=31536000, stale-while-revalidate=86400.
The browser keeps a clip for an hour, and the CDN keeps it for a year, keyed by the full URL. The
client splits a chapter into chunks of at most 700 characters, and a chunk's text only changes
when the chapter does. So the same chunk in the same voice, rate and pitch is synthesised once
per region, and every later listener is served a file. maxDuration is 20 seconds.
Failures: 400 for missing text or an unknown voice, 413 for text over the limit, 429 from
the limiter, and 502 when synthesis throws. There is also a POST handler that
returns base64 audio inside JSON with no-store. It predates the binary format and
nothing in the client calls it any more.
msedge-tts reaches a Microsoft service meant for the Edge browser's read-aloud
feature. It is free and sounds much better than speechSynthesis, but nobody has
promised it will keep working. That is why the narrator is a button on a page that reads fine
without it.
/api/joke
It takes no input and calls JokeAPI's Programming category with
safe-mode, with a five-minute revalidate and a matching
max-age. It is the only endpoint built never to fail visibly: a non-OK upstream, an
error flag or a network error all return a hardcoded dark-mode joke with a 200. The fallback
carries no cache header, so the next request tries JokeAPI again. The one case that leaks through
is the limiter. A 429 body has no text field, so JokeCard leaves its
skeleton line in place.
/api/client-error
POST only. The body is refused with a 413 if the declared
content-length or the actual text is over 4,000 bytes. Malformed JSON gets a 204,
since there is nothing useful to say back. The message, stack and URL are each clipped to 500
characters and the user agent to 200. The result is written as one JSON line with
console.error, which puts it in Vercel's function log, and the response is 204.
ErrorReporter in the root layout listens for error and
unhandledrejection. It sends at most five reports per page load, with
keepalive so they survive navigation. It only does this when
NEXT_PUBLIC_SENTRY_DSN is empty. When a DSN is set, instrumentation.ts
and instrumentation-client.ts start Sentry instead, and reportError() in
lib/errorTracking.ts sends to Sentry first. So this endpoint is the floor, not the
plan. The function log has no grouping and no alerting, and entries expire.
The static JSON routes
/problems/[slug]/cases returns the recording made by
recordPolyglot at build time: a signature and recorded cases, or
{ ok: false, reason }. Most files are a 97-byte refusal. The largest is about 1 MB.
dynamicParams = false, so an unknown slug is a 404 without any code running. The
client in PracticeWorkspace keeps one promise per exercise in a Map. It
turns a non-OK response into "Could not load it." and a network error into "Could not load it —
are you offline?". Either way the reader can still run and grade JavaScript.
/mock/bank/[stage] serves one question bank for each of the 12 interview
stages: 792 items and 474,513 bytes in total. They range from 5 KB for the résumé round to 91 KB
for coding. The page itself only gets mockCatalog() as a prop, meaning stage titles,
rules and counts, so the banks stay out of the HTML. fetchStage() caches a promise
per stage. On failure it removes the promise from the cache, so a retry really does retry, and it
throws a message with the HTTP status for the lobby to show.
The search indexes take no input. The search chapter covers them.
If the response depends only on the repository, it belongs in a force-static route
and gets built once. That is how the test cases and question banks were done. A function is only
justified when the answer depends on the request or on something outside the repository. Even
then, the page that calls it has to work when it returns a 502.
Finished reading?
0 of 26 chapters read in this series