Build the API endpoint
Turn the pipeline into a web API: a request handler that validates, serves cached decks, enforces quotas and refunds failures, plus a small Node.js server that only speaks HTTP.
The pipeline, cache, quota and validation all exist as functions. This lesson puts them behind a URL: POST /api/deck with a topic in, a deck out.
Browser
POST /api/deck
server.js
read JSON (size-limited)
handler.js
validate · cache · quota
pipeline.js
the deck
pptx.js
the file
The handler: the work, without HTTP
A is a plain function: handleDeckRequest({ body, userId }, deps) returns { status, json }. It never touches a request or response object. The order of its steps is deliberate:
- Validate. A bad request costs nothing and uses no quota.
- Check the cache. A repeat is free, so it shouldn't use quota either.
- Consume quota. Only now is real money about to be spent.
- Generate and build the file, then save it in the store.
- On failure, refund and return 429 for rate limits and quotas (so clients know to back off) or 502 otherwise, with a friendly message.
server.js: only HTTP
Built with node:http, no framework, so every moving part is visible:
- Reads JSON bodies with a size cap (50 KB). Without one, a client can send a gigabyte and your server reads it all into memory.
- Serves the web page from
public/, refusing any path that resolves outside it. - Only shows error messages it created itself; anything unexpected becomes "Something went wrong".
Your project so far
32 files · 2 new or changed in this lesson
server.js
// A small web server with no framework: node:http is enough to show every moving part.
// node server.js then open http://localhost:3000
//
// It serves the files in public/ and one API route. All the real work happens in
// src/handler.js; this file only speaks HTTP.
import http from "node:http";
import fs from "node:fs/promises";
import path from "node:path";
import { handleDeckRequest } from "./src/handler.js";
const PORT = Number(process.env.PORT ?? 3000);
const PUBLIC_DIR = path.join(import.meta.dirname, "public");
const MAX_BODY_BYTES = 50_000;
const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8" };
const server = http.createServer(async (request, response) => {
try {
if (request.method === "POST" && request.url === "/api/deck") {
const body = await readJson(request);
const { status, json } = await handleDeckRequest({ body, userId: clientId(request) });
return send(response, status, JSON.stringify(json), "application/json");
}
if (request.method === "GET") return await serveStatic(request, response);
send(response, 405, "Method not allowed", "text/plain");
} catch (error) {
// Only errors we created on purpose (like "body too large") have a message safe to show.
const status = error.status ?? 500;
send(response, status, JSON.stringify({ error: error.expose ? error.message : "Something went wrong." }), "application/json");
}
});
server.listen(PORT, () => console.log(`Slide generator running on http://localhost:${PORT}`));
/** Read a JSON body, refusing anything bigger than MAX_BODY_BYTES. */
async function readJson(request) {
let size = 0;
const chunks = [];
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw httpError(413, "That request is too large.");
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
} catch {
throw httpError(400, "The request body isn't valid JSON.");
}
}
/** Serve a file from public/, and nothing outside it. */
async function serveStatic(request, response) {
const url = new URL(request.url, "http://localhost");
const relative = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname).replace(/^\/+/, "");
const file = path.resolve(PUBLIC_DIR, relative);
if (!file.startsWith(PUBLIC_DIR + path.sep)) return send(response, 403, "Forbidden", "text/plain"); // "../../etc/passwd"
try {
send(response, 200, await fs.readFile(file), TYPES[path.extname(file)] ?? "application/octet-stream");
} catch {
send(response, 404, "Not found", "text/plain");
}
}
/** Who is asking. There is no login here, so the IP address stands in for a user id. */
function clientId(request) {
return String(request.headers["x-forwarded-for"] ?? request.socket.remoteAddress ?? "anonymous").split(",")[0].trim();
}
function send(response, status, body, type) {
response.writeHead(status, { "content-type": type, "x-content-type-options": "nosniff" });
response.end(body);
}
function httpError(status, message) {
return Object.assign(new Error(message), { status, expose: true });
}Key takeaways
- Keep HTTP in server.js and the work in a plain handler function you can test without a server.
- Order matters: validate, check the cache, consume quota, generate, and refund on failure.
- Never trust the client: limit body size, and only serve files from the public folder.
Sign in to run the exercise
Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +40 XP you are about to earn.