Purrx

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.

14 min+40 XPHands-on

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

server.js only speaks HTTP. The handler does the work as a plain function, so it can be tested without a server and moved to any framework.

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:

  1. Validate. A bad request costs nothing and uses no quota.
  2. Check the cache. A repeat is free, so it shouldn't use quota either.
  3. Consume quota. Only now is real money about to be spent.
  4. Generate and build the file, then save it in the store.
  5. 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.