How this is builtGroundworkHow this is built
17 / 26 written chapters

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.

6 min read1 diagram6 sections
06On this page
  1. The whole list
  2. What the three share
  3. /api/tts
  4. /api/joke
  5. /api/client-error
  6. The static JSON routes

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.

RouteKindCalled byIf it fails
/api/ttsFunctionThe Listen buttonThe narrator does not start; the chapter reads normally
/api/jokeFunctionThe card on /progressA hardcoded joke, or a skeleton line
/api/client-errorFunctionErrorReporterThe error is not logged
/problems/{id}/casesStatic, 538 filesThe playgroundOnly JavaScript is graded
/mock/bank/{stage}Static, 12 filesThe mock lobbyThe loop cannot start, and the lobby says why
{topic}/search-index.jsonStatic, 21 filesThe chapter search boxSearch 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.

Listen button narration.ts /progress JokeCard Uncaught error ErrorReporter /api/tts · 40/min GET, kept a year at the CDN /api/joke · 20/min falls back to a fixed joke /api/client-error · 20/min 4,000-byte body cap Microsoft Edge voices msedge-tts over a WebSocket JokeAPI fetch data cache, 5 minutes Function log one JSON line per error
The three functions, what calls each one, and what each one calls in turn. Every limit is per IP, per minute, and per function instance.

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.

The one dependency with no contract

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.

The test for adding an endpoint

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.