The Hectoday HTTP guide

You are about to build a complete API from scratch. By the end of this guide, you will have an app with routes, input validation, authentication, error handling, CORS, OpenAPI documentation, and tests. Every concept is introduced when you need it, explained before it is used, and built on top of what came before.

We are building a bookmarks API. Users can save, list, retrieve, and delete bookmarks. It is simple enough to understand quickly, but complex enough to show every feature of the framework. You can code along with every step.

What is Hectoday HTTP?

Hectoday HTTP is a TypeScript framework for building HTTP APIs. It uses Web Standard Request and Response objects, the same APIs that browsers use. There is no middleware, no magic, and no proprietary abstractions. You define routes, validate input with Zod, and return standard Response objects.

The framework has four main concepts:

  1. Routes map URL patterns to handler functions.
  2. Validation uses Zod schemas to validate and type request input.
  3. Lifecycle callbacks like onRequest, onResponse, onError, and onNotFound let you hook into the request lifecycle.
  4. Setup wires everything together with a single setup() call.

That probably sounds abstract right now. It will make sense once we start writing code.


Getting started

Install

Create a new directory and install the dependencies:

mkdir bookmarks-api
cd bookmarks-api
npm init -y
npm install @hectoday/http zod srvx
npm install -D typescript @types/node tsx

Three runtime dependencies:

  • @hectoday/http is the framework itself.
  • zod handles schema validation. We will use it to validate request bodies, query parameters, and path parameters.
  • srvx starts the HTTP server. It takes a fetch function and listens for incoming requests.

Now open package.json and add "type": "module" and a dev script:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts"
  }
}

tsx watch runs your TypeScript directly and restarts automatically when you save a file. No build step needed during development.

Create a tsconfig.json file in the root of your project:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

This tells TypeScript to compile the files inside the src folder and output them to dist. You do not need to worry about most of these options right now. The important one is strict: true, which catches more bugs at compile time.

Your first route

Create a file at src/server.ts:

import { setup, route } from "@hectoday/http";
import { serve } from "srvx";

const app = setup({
  routes: [
    route.get("/hello", {
      resolve: () => Response.json({ message: "Hello, world!" }),
    }),
  ],
});

serve({ fetch: app.fetch, port: 3000 });
console.log("Running at http://localhost:3000");

Start the server:

npm run dev
curl http://localhost:3000/hello
# {"message":"Hello, world!"}

Let’s walk through what just happened.

setup() creates your application. You pass it a configuration object with a routes array. Every endpoint in your API is a route inside this array.

route.get("/hello", { resolve: ... }) defines a GET endpoint at the path /hello. The resolve function is the handler. It runs when an incoming request matches this method and path.

Response.json() is a Web Standard API built into Node.js. It creates a JSON response with the right Content-Type header. There is no framework-specific response object here. You are returning a standard Response, the same kind that exists in browsers.

app.fetch is a function with the signature (request: Request) => Promise<Response>. You pass it to srvx’s serve() function to start listening for HTTP requests on port 3000.

That is your first working API. One route, one handler, one response. Let’s make it do something real.


A data store

Before we add more routes, we need somewhere to store bookmarks. In a real app, this would be a database. For this guide, we will use a simple in-memory store so we can focus on learning the framework without setting up a database.

Create src/data.ts:

export interface Bookmark {
  id: string;
  url: string;
  title: string;
  tags: string[];
  userId: string;
  createdAt: string;
}

const bookmarks = new Map<string, Bookmark>();

export function createBookmark(input: {
  url: string;
  title: string;
  tags: string[];
  userId: string;
}): Bookmark {
  const bookmark: Bookmark = {
    id: crypto.randomUUID(),
    ...input,
    createdAt: new Date().toISOString(),
  };
  bookmarks.set(bookmark.id, bookmark);
  return bookmark;
}

export function findById(id: string): Bookmark | undefined {
  return bookmarks.get(id);
}

export function findAllByUser(userId: string): Bookmark[] {
  return [...bookmarks.values()].filter((b) => b.userId === userId);
}

export function deleteById(id: string): boolean {
  return bookmarks.delete(id);
}

The bookmarks Map stores bookmarks in memory, keyed by their ID. The helper functions handle creating, finding, listing, and deleting bookmarks. Each bookmark has an id (a random UUID), the url and title the user provides, an array of tags, the userId of who created it, and a createdAt timestamp.

This data disappears when you restart the server. That is fine for learning. Swapping this for a database later means changing these functions and nothing else.


Routes

Listing bookmarks

Let’s replace the hello world route with something real. Update src/server.ts to import the data store and add a GET route that lists bookmarks:

import { setup, route } from "@hectoday/http";
import { serve } from "srvx";
import { findAllByUser } from "./data.js";

const app = setup({
  routes: [
    route.get("/bookmarks", {
      resolve: () => {
        const bookmarks = findAllByUser("anonymous");
        return Response.json(bookmarks);
      },
    }),
  ],
});

serve({ fetch: app.fetch, port: 3000 });
console.log("Running at http://localhost:3000");
curl http://localhost:3000/bookmarks
# []

An empty array. That makes sense because we have not created any bookmarks yet. Let’s fix that.

Creating bookmarks

Add a POST route right after the GET route. You will also need to import createBookmark from the data store:

import { findAllByUser, createBookmark } from "./data.js";
route.post("/bookmarks", {
  resolve: async (c) => {
    const body = await c.request.json();
    const bookmark = createBookmark({
      ...body,
      tags: body.tags ?? [],
      userId: "anonymous",
    });
    return Response.json(bookmark, { status: 201 });
  },
}),

Notice the c argument. That is the context object, which gives your handler access to the request, validated input, and other useful data. Here we use c.request.json() to read the POST body as JSON.

The second argument to Response.json() sets the status code to 201, which means “created.” This is the standard response for successful creation.

Try it out:

curl -X POST http://localhost:3000/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "title": "Example"}'
# {"id":"a1b2c3...","url":"https://example.com","title":"Example",...}

curl http://localhost:3000/bookmarks
# [{"id":"a1b2c3...","url":"https://example.com","title":"Example",...}]

It works. But there is a problem with this POST handler: we are trusting whatever the client sends. They could send { "url": 12345 } or just "garbage" and our code would happily try to use it. We will fix this with validation soon.

Path parameters

Now we need a way to fetch a single bookmark by its ID. The URL should look like /bookmarks/a1b2c3. But we cannot hardcode the ID in the route path because it is different for every bookmark. That is where path parameters come in.

Add this route and import findById:

import { findAllByUser, createBookmark, findById } from "./data.js";
route.get("/bookmarks/:id", {
  resolve: (c) => {
    const url = new URL(c.request.url);
    const id = url.pathname.split("/").pop()!;
    const bookmark = findById(id);
    if (!bookmark) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }
    return Response.json(bookmark);
  },
}),

The :id part of the path tells the framework which requests to match. A request to /bookmarks/abc-123 matches this route. We extract the ID from the URL using c.request.url.

This works, but extracting the ID manually is clunky. When we add validation later, the framework will parse path parameters for us automatically. For now, this gets the job done.

# Use an id from a bookmark you created earlier
curl http://localhost:3000/bookmarks/PASTE-ID-HERE

Deleting bookmarks

Add a DELETE route. Import deleteById from the data store:

import { findAllByUser, createBookmark, findById, deleteById } from "./data.js";
route.delete("/bookmarks/:id", {
  resolve: (c) => {
    const url = new URL(c.request.url);
    const id = url.pathname.split("/").pop()!;
    deleteById(id);
    return new Response(null, { status: 204 });
  },
}),

This handler returns new Response(null, { status: 204 }). A 204 means “success, but no content to return.” This is the standard response for deletions.

curl -X DELETE http://localhost:3000/bookmarks/PASTE-ID-HERE
# (empty response, 204 status)

Route order matters

Routes are matched in the order you register them. This matters when you have paths that could overlap:

routes: [
  route.get("/bookmarks/popular", { resolve: ... }),  // Matches first
  route.get("/bookmarks/:id", { resolve: ... }),       // Matches if "popular" didn't
],

If you put /bookmarks/:id first, a request to /bookmarks/popular would match it with id = "popular". That is probably not what you want. Put specific routes before general ones.

Other response patterns

So far we have used Response.json() and new Response(null, { status: 204 }). Here is a quick reference for the response patterns you will use most often:

// JSON with the default 200 status
Response.json({ title: "My bookmark" });

// JSON with a specific status code
Response.json({ title: "Created" }, { status: 201 });

// Empty response (204 No Content), common for DELETE
new Response(null, { status: 204 });

// JSON with custom headers
new Response(JSON.stringify(data), {
  status: 200,
  headers: {
    "Content-Type": "application/json",
    "Cache-Control": "public, max-age=60",
  },
});

Most of the time, Response.json() is all you need. The other forms are there when you want finer control over headers or status codes.


The context object

Every handler receives a context object c. We have already used c.request to read the URL and headers. Let’s look at all three properties.

c.request

This is the original Web Standard Request. Use it for headers, the raw body, the URL, or anything the framework does not parse for you:

resolve: (c) => {
  const auth = c.request.headers.get("authorization");
  const url = new URL(c.request.url);
  const method = c.request.method;
  return Response.json({ auth, path: url.pathname, method });
},

c.request is the same Request object you would get in a Service Worker or a Cloudflare Worker. If you know how to use the Web Standard Request API, you already know how to use this.

c.input

This is where validated request data lives. When you add a request schema to a route, c.input contains the parsed body, query parameters, and path parameters. It is only available on routes that define at least one schema. We are about to cover this in the next section.

c.locals

This is data set by the onRequest callback. Use it for things like request IDs, authenticated user info, or timing. We will cover this when we get to lifecycle callbacks.


Validation with Zod

Why validate?

Right now, our POST handler trusts whatever the client sends. What do you think happens if someone sends { "url": 12345 } instead of a proper URL string? It gets stored in our data map with a number where a string should be. No error, no warning. The data is silently wrong.

Or what if they send an empty body? await c.request.json() throws a SyntaxError, and the user gets a confusing 500 error. That is a bad experience for everyone.

Zod schemas fix this. You define the expected shape of the data, and the framework validates the input before your handler runs.

Body validation

Let’s add validation to our POST route. First, define a schema. Add this near the top of src/server.ts, after the imports:

import { z } from "zod/v4";

const CreateBookmark = z.object({
  url: z.url(),
  title: z.string().min(1).max(200),
  tags: z.array(z.string().min(1)).max(10).default([]),
});

This schema says: “I expect an object with a url that is a valid URL, a title that is between 1 and 200 characters, and an optional tags array of up to 10 non-empty strings. If tags is not provided, default to an empty array.”

Now update the POST route to use it:

route.post("/bookmarks", {
  request: { body: CreateBookmark },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    const body = c.input.body;
    const bookmark = createBookmark({ ...body, userId: "anonymous" });
    return Response.json(bookmark, { status: 201 });
  },
}),

Compare this to the old version. We replaced the manual await c.request.json() with request: { body: CreateBookmark }. When you add this, the framework does three things:

  1. It reads the request body as JSON.
  2. It validates the JSON against the CreateBookmark schema.
  3. It puts the result on c.input.

Then you check c.input.ok. If it is true, validation passed and c.input.body contains the typed data, inferred from the schema you provided. If it is false, validation failed and c.input.issues contains the error details.

Try sending invalid data:

curl -X POST http://localhost:3000/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "not-a-url", "title": ""}'
# Returns 400 with validation errors

Notice something important: the framework does not automatically reject invalid requests. You decide what to do. You can return a 400, return a default value, or ignore certain fields. This is intentional. Explicit control is better than invisible behavior.

Query validation

Query parameters come from the URL. A request to /bookmarks?tag=dev&page=2 has two query parameters: tag with the value "dev" and page with the value "2".

Here is the tricky part: query parameters are always strings. The number 2 arrives as the string "2". If your schema expects a number, you need z.coerce to convert it.

Let’s add query validation to our GET /bookmarks route. First, define the schema next to CreateBookmark:

const BookmarkQuery = z.object({
  tag: z.string().optional(),
  search: z.string().optional(),
  sort: z.enum(["title", "createdAt"]).default("createdAt"),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

Now update the GET /bookmarks route to use it:

route.get("/bookmarks", {
  request: { query: BookmarkQuery },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    const { tag, search, sort, page, limit } = c.input.query;
    // For now, we ignore the query params and return all bookmarks.
    // In a real app, you would filter and paginate here.
    const bookmarks = findAllByUser("anonymous");
    return Response.json(bookmarks);
  },
}),

z.coerce.number() converts the string "2" to the number 2. Without coercion, "2" would fail number validation because it is technically a string.

.default() provides a value when the parameter is missing entirely. A request to /bookmarks with no query params at all gets { sort: "createdAt", page: 1, limit: 20 }. The user does not need to specify every parameter.

The query types are fully inferred from the schema, just like body and params. tag is string | undefined, page is number, and sort is "title" | "createdAt".

Params validation

Path parameters can be validated too. Right now we are extracting the ID manually from the URL, which is clunky. A params schema fixes that. Update the GET /bookmarks/:id route:

route.get("/bookmarks/:id", {
  request: { params: z.object({ id: z.uuid() }) },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ error: "Invalid bookmark ID" }, { status: 400 });
    }
    const { id } = c.input.params;
    const bookmark = findById(id);
    if (!bookmark) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }
    return Response.json(bookmark);
  },
}),

Notice how much cleaner this is compared to manually parsing the URL. The framework extracts the path parameters, validates them against the schema, and gives you fully typed access on c.input.params. Now a request to /bookmarks/not-a-uuid gets a clean 400 error instead of silently being treated as a valid ID.

Also update the DELETE route the same way:

route.delete("/bookmarks/:id", {
  request: { params: z.object({ id: z.uuid() }) },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ error: "Invalid bookmark ID" }, { status: 400 });
    }
    const { id } = c.input.params;
    deleteById(id);
    return new Response(null, { status: 204 });
  },
}),

Combining all three

A single route can validate body, query, and params at the same time. Let’s add a PUT route for updating bookmarks. Add updateBookmark to your data store imports and add this route:

route.put("/bookmarks/:id", {
  request: {
    params: z.object({ id: z.uuid() }),
    body: z.object({ url: z.url(), title: z.string() }),
  },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    const { id } = c.input.params;
    const { url, title } = c.input.body;

    const bookmark = findById(id);
    if (!bookmark) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }

    bookmark.url = url;
    bookmark.title = title;
    return Response.json(bookmark);
  },
}),

Both schemas run, and if either fails, c.input.ok is false. You get fully typed access to both c.input.params and c.input.body without any casts.

Try it:

curl -X PUT http://localhost:3000/bookmarks/PASTE-ID-HERE \
  -H "Content-Type: application/json" \
  -d '{"url": "https://updated.com", "title": "Updated"}'

Formatting validation errors

c.input.issues is a Zod issues array. It works fine as-is, but you might want to format it into a more structured response with field-level errors:

if (!c.input.ok) {
  const fields: Record<string, string[]> = {};
  for (const issue of c.input.issues ?? []) {
    const path = issue.path?.join(".") ?? "unknown";
    if (!fields[path]) fields[path] = [];
    fields[path].push(issue.message);
  }

  return Response.json(
    {
      error: {
        code: "VALIDATION_ERROR",
        message: "Invalid input",
        fields,
      },
    },
    { status: 400 },
  );
}

This loops through each validation issue, groups them by field path, and produces a response like this:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "fields": {
      "url": ["Invalid url"],
      "title": ["String must contain at least 1 character(s)"]
    }
  }
}

A frontend can read error.fields and show the right message next to each form field. This is exactly how production apps handle validation errors.


Lifecycle callbacks

Our app creates and reads bookmarks, and it validates input. But it is missing some basics that every production API needs: request IDs for debugging, response timing for monitoring, and consistent error handling for unexpected crashes.

The framework has four lifecycle callbacks for this. They are all optional and defined on the setup() config, not on individual routes. Think of them as hooks that run at specific points during every request.

onRequest

This runs before every route handler. Use it for setting up per-request data like request IDs and timing. Update your setup() call to add it:

const app = setup({
  onRequest: ({ request }) => {
    const requestId = crypto.randomUUID();
    console.log(`-> ${request.method} ${new URL(request.url).pathname}`);
    return { requestId, startTime: Date.now() };
  },
  routes: [
    /* ... your existing routes ... */
  ],
});

Whatever you return from onRequest becomes c.locals in every route handler:

resolve: (c) => {
  console.log("Request ID:", c.locals.requestId);
  console.log("Started at:", c.locals.startTime);
  return Response.json({ ok: true });
},

If onRequest does not return anything, c.locals is an empty object.

Keep onRequest lean. It runs on every single request, including public ones. Stick to universal concerns like request IDs and timing.

onResponse

This runs after every route handler. Add it to your setup() config, right after onRequest:

onResponse: ({ response, locals }) => {
  const duration = Date.now() - locals.startTime;
  response.headers.set("X-Request-Id", locals.requestId);
  response.headers.set("X-Response-Time", `${duration}ms`);
  console.log(`<- ${response.status} in ${duration}ms`);
  return response;
},

onResponse receives the response, lets you modify it (like adding headers), and must return it. You have to return the response, otherwise the framework has nothing to send back to the client. This is where you attach the request ID and timing headers that onRequest set up. Try hitting any endpoint and check the response headers:

curl -v http://localhost:3000/bookmarks 2>&1 | grep -i "x-"
# X-Request-Id: a1b2c3d4-...
# X-Response-Time: 2ms

onError

This runs when a route handler or onRequest throws an actual Error. Not a validation failure. Not a “not found.” A real, unexpected crash. A bug in your code. Add it to your setup() config:

onError: ({ error, request, locals }) => {
  const url = new URL(request.url);
  console.error(`[${locals?.requestId}] ${request.method} ${url.pathname}:`, error.stack);

  return Response.json(
    { error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" } },
    { status: 500 }
  );
},

Notice that locals is Partial here. That is because onRequest might not have finished before the error happened, so some fields could be missing.

When does onError run?

Only when something is genuinely wrong. An unhandled exception, a TypeError, a null reference, a database connection failure. Things you did not anticipate:

resolve: (c) => {
  const data = JSON.parse(someCorruptedString); // TypeError: goes to onError
  const result = undefined.foo;                  // TypeError: goes to onError
  db.prepare("INVALID SQL").run();               // SqliteError: goes to onError
},

These are bugs. They should not happen during normal operation, and onError is the safety net that catches them.

When does onError NOT run?

For expected outcomes like “not found,” “unauthorized,” “validation failure,” and “forbidden,” return a Response directly. Do not throw. Look at our GET /bookmarks/:id handler:

resolve: (c) => {
  if (!c.input.ok) {
    return Response.json({ error: "Invalid bookmark ID" }, { status: 400 }); // 400: returned
  }
  const { id } = c.input.params;
  const bookmark = findById(id);
  if (!bookmark) {
    return Response.json({ error: "Not found" }, { status: 404 });           // 404: returned
  }
  return Response.json(bookmark);                                             // 200: returned
},

Every outcome is a returned Response. Nothing is thrown. onError never runs for this handler unless something truly unexpected happens, like findById crashing because of a bug.

The philosophy

Return responses for expected outcomes. Let errors happen for unexpected ones:

  • 404 Not Found: return a Response (expected, the resource does not exist)
  • 401 Unauthorized: return a Response (expected, no token or bad token)
  • 403 Forbidden: return a Response (expected, wrong user)
  • 400 Validation: return a Response (expected, bad input)
  • 500 Internal Error: onError catches it (unexpected, a bug)

This makes handlers predictable. Reading the code, you can see every possible response. Nothing is hidden in a catch block somewhere else.

onNotFound

This runs when no route matches the request. Add it to your setup() config:

onNotFound: ({ request }) => {
  const url = new URL(request.url);
  return Response.json(
    { error: { code: "NOT_FOUND", message: `No route for ${request.method} ${url.pathname}` } },
    { status: 404 }
  );
},

Without onNotFound, the framework returns a plain default 404 response. Setting it lets you keep your error format consistent across the entire API.

curl http://localhost:3000/nonexistent
# {"error":{"code":"NOT_FOUND","message":"No route for GET /nonexistent"}}

Lifecycle flow

Here is how everything fits together:

Request arrives
    |
    v
onRequest()          returns locals
    |
    v
route handler()      returns Response
    |
    v
onResponse()         modifies and returns response
    |
    v
Response sent


If something unexpectedly throws an Error (a bug):
    onError()  returns 500 Response  onResponse()  sent

In normal operation, nothing throws. Handlers return Response objects for every outcome: 200, 201, 400, 404. onError is the safety net for genuine crashes, not a control flow mechanism.


Authentication

Our API has a problem: anyone can create, read, and delete anyone’s bookmarks. There is no concept of “who is making this request.” Let’s fix that.

We need a function that reads a token from the request, figures out who the user is, and either returns their identity or an error. Create src/auth.ts:

export function authenticate(request: Request): string | Response {
  const auth = request.headers.get("authorization");
  if (!auth?.startsWith("Bearer ")) {
    return Response.json(
      { error: { code: "UNAUTHORIZED", message: "Bearer token required" } },
      { status: 401 },
    );
  }

  const token = auth.replace("Bearer ", "").trim();
  if (token.length === 0) {
    return Response.json(
      { error: { code: "UNAUTHORIZED", message: "Token is empty" } },
      { status: 401 },
    );
  }

  // In a real app, you would verify a JWT or look up a session here.
  // For this guide, we treat the token itself as the user ID.
  return token;
}

Look at the return type: string | Response. If authentication succeeds, you get a string (the user ID). If it fails, you get a Response (the error). No exceptions are thrown.

For this guide, we are using the simplest possible auth: the Bearer token itself is the user ID. If you send Authorization: Bearer alice, you are user alice. In a real app, you would verify a JWT or look up a session token in a database, but the pattern is exactly the same.

Using it in routes

Now update src/server.ts. Import the authenticate function and use it in the routes that need protection:

import { authenticate } from "./auth.js";

Update the routes:

route.get("/bookmarks", {
  resolve: (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

    const bookmarks = findAllByUser(caller);
    return Response.json(bookmarks);
  },
}),
route.post("/bookmarks", {
  request: { body: CreateBookmark },
  resolve: (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    const body = c.input.body;
    const bookmark = createBookmark({ ...body, userId: caller });
    return Response.json(bookmark, { status: 201 });
  },
}),
route.put("/bookmarks/:id", {
  request: {
    params: z.object({ id: z.uuid() }),
    body: z.object({ url: z.url(), title: z.string() }),
  },
  resolve: (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    const { id } = c.input.params;
    const bookmark = findById(id);
    if (!bookmark) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }
    if (bookmark.userId !== caller) {
      return Response.json({ error: "Forbidden" }, { status: 403 });
    }

    bookmark.url = c.input.body.url;
    bookmark.title = c.input.body.title;
    return Response.json(bookmark);
  },
}),
route.get("/bookmarks/:id", {
  request: { params: z.object({ id: z.uuid() }) },
  resolve: (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

    if (!c.input.ok) {
      return Response.json({ error: "Invalid bookmark ID" }, { status: 400 });
    }

    const { id } = c.input.params;
    const bookmark = findById(id);
    if (!bookmark) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }
    if (bookmark.userId !== caller) {
      return Response.json({ error: "Forbidden" }, { status: 403 });
    }

    return Response.json(bookmark);
  },
}),
route.delete("/bookmarks/:id", {
  request: { params: z.object({ id: z.uuid() }) },
  resolve: (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

    if (!c.input.ok) {
      return Response.json({ error: "Invalid bookmark ID" }, { status: 400 });
    }

    const { id } = c.input.params;
    const bookmark = findById(id);
    if (!bookmark || bookmark.userId !== caller) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }

    deleteById(id);
    return new Response(null, { status: 204 });
  },
}),

The pattern is always the same: call the helper, check if the result is a Response, return early if it is. If authenticate returns a Response, that means auth failed, and we immediately send that 401 error back. The rest of the handler never runs.

Let’s also add a public health check route that does not call authenticate:

route.get("/health", {
  resolve: () => Response.json({ status: "ok" }),
}),

No list of public paths. No conditional logic in onRequest. Each route decides for itself whether it needs auth.

Try it out:

# Without auth: 401
curl http://localhost:3000/bookmarks
# {"error":{"code":"UNAUTHORIZED","message":"Bearer token required"}}

# With auth: 200
curl http://localhost:3000/bookmarks -H "Authorization: Bearer alice"
# []

# Create a bookmark as alice
curl -X POST http://localhost:3000/bookmarks \
  -H "Authorization: Bearer alice" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "title": "Example"}'

# Bob cannot see alice's bookmark
curl http://localhost:3000/bookmarks/PASTE-ID-HERE -H "Authorization: Bearer bob"
# {"error":"Forbidden"}

Why not put auth in onRequest?

You might be tempted to put authentication in onRequest. After all, it runs before every handler, so it seems like the right place to check tokens. But this creates problems:

  • Not every route needs auth. Our health check is public. Documentation endpoints should be public. Login endpoints need to be public.
  • Different routes might need different auth. Some use JWT tokens, some use API keys, some need admin privileges.
  • You end up maintaining a hardcoded list of “public paths” that grows over time and becomes fragile.

Keep onRequest for universal concerns like request IDs and timing. Auth is a per-route concern, and the helper function pattern makes that clean.

Different auth for different routes

The helper pattern scales to multiple auth strategies. Here is what an admin check looks like:

export function authenticateAdmin(request: Request): string | Response {
  const caller = authenticate(request);
  if (caller instanceof Response) return caller;

  // In a real app, check a database or a role claim in the JWT
  const admins = ["admin", "superadmin"];
  if (!admins.includes(caller)) {
    return Response.json(
      { error: { code: "FORBIDDEN", message: "Admin access required" } },
      { status: 403 },
    );
  }
  return caller;
}

authenticateAdmin calls authenticate first and then adds an extra check on top. You can compose these functions however you want. Each route picks the auth it needs. No global config, no middleware chain.


Error response helpers

Our routes have a lot of repeated error shapes. The “Not found” response appears in multiple handlers, and each one constructs it slightly differently. Let’s clean that up.

Create src/responses.ts:

export function notFound(message: string): Response {
  return Response.json({ error: { code: "NOT_FOUND", message } }, { status: 404 });
}

export function forbidden(message: string): Response {
  return Response.json({ error: { code: "FORBIDDEN", message } }, { status: 403 });
}

export function badRequest(message: string): Response {
  return Response.json({ error: { code: "VALIDATION_ERROR", message } }, { status: 400 });
}

Now update your routes to use them. Import at the top of src/server.ts:

import { notFound, forbidden, badRequest } from "./responses.js";

Here is what the GET /bookmarks/:id route looks like now:

route.get("/bookmarks/:id", {
  request: { params: z.object({ id: z.uuid() }) },
  resolve: (c) => {
    const caller = authenticate(c.request);
    if (caller instanceof Response) return caller;

    if (!c.input.ok) return badRequest("Invalid bookmark ID");
    const { id } = c.input.params;

    const bookmark = findById(id);
    if (!bookmark) return notFound("Bookmark not found");
    if (bookmark.userId !== caller) return forbidden("Not your bookmark");

    return Response.json(bookmark);
  },
}),

Every line is a possible return path. You can read the handler from top to bottom and understand every outcome.


The full app so far

Here is the complete src/server.ts with everything we have built. If you have been coding along, your file should look like this:

import { setup, route } from "@hectoday/http";
import { z } from "zod/v4";
import { serve } from "srvx";
import { createBookmark, findById, findAllByUser, deleteById } from "./data.js";
import { authenticate } from "./auth.js";
import { notFound, forbidden, badRequest } from "./responses.js";

const CreateBookmark = z.object({
  url: z.url(),
  title: z.string().min(1).max(200),
  tags: z.array(z.string().min(1)).max(10).default([]),
});

export const app = setup({
  onRequest: ({ request }) => {
    const requestId = crypto.randomUUID();
    console.log(`-> ${request.method} ${new URL(request.url).pathname}`);
    return { requestId, startTime: Date.now() };
  },
  onResponse: ({ response, locals }) => {
    const duration = Date.now() - locals.startTime;
    response.headers.set("X-Request-Id", locals.requestId);
    response.headers.set("X-Response-Time", `${duration}ms`);
    console.log(`<- ${response.status} in ${duration}ms`);
    return response;
  },
  onError: ({ error, request, locals }) => {
    const url = new URL(request.url);
    console.error(`[${locals?.requestId}] ${request.method} ${url.pathname}:`, error.stack);
    return Response.json(
      { error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" } },
      { status: 500 },
    );
  },
  onNotFound: ({ request }) => {
    const url = new URL(request.url);
    return Response.json(
      { error: { code: "NOT_FOUND", message: `No route for ${request.method} ${url.pathname}` } },
      { status: 404 },
    );
  },
  routes: [
    route.get("/health", {
      resolve: () => Response.json({ status: "ok" }),
    }),
    route.get("/bookmarks", {
      resolve: (c) => {
        const caller = authenticate(c.request);
        if (caller instanceof Response) return caller;
        return Response.json(findAllByUser(caller));
      },
    }),
    route.post("/bookmarks", {
      request: { body: CreateBookmark },
      resolve: (c) => {
        const caller = authenticate(c.request);
        if (caller instanceof Response) return caller;
        if (!c.input.ok) return badRequest("Invalid input");
        const body = c.input.body;
        const bookmark = createBookmark({ ...body, userId: caller });
        return Response.json(bookmark, { status: 201 });
      },
    }),
    route.put("/bookmarks/:id", {
      request: {
        params: z.object({ id: z.uuid() }),
        body: z.object({ url: z.url(), title: z.string() }),
      },
      resolve: (c) => {
        const caller = authenticate(c.request);
        if (caller instanceof Response) return caller;
        if (!c.input.ok) return badRequest("Invalid input");
        const { id } = c.input.params;
        const bookmark = findById(id);
        if (!bookmark) return notFound("Bookmark not found");
        if (bookmark.userId !== caller) return forbidden("Not your bookmark");
        bookmark.url = c.input.body.url;
        bookmark.title = c.input.body.title;
        return Response.json(bookmark);
      },
    }),
    route.get("/bookmarks/:id", {
      request: { params: z.object({ id: z.uuid() }) },
      resolve: (c) => {
        const caller = authenticate(c.request);
        if (caller instanceof Response) return caller;
        if (!c.input.ok) return badRequest("Invalid bookmark ID");
        const { id } = c.input.params;
        const bookmark = findById(id);
        if (!bookmark) return notFound("Bookmark not found");
        if (bookmark.userId !== caller) return forbidden("Not your bookmark");
        return Response.json(bookmark);
      },
    }),
    route.delete("/bookmarks/:id", {
      request: { params: z.object({ id: z.uuid() }) },
      resolve: (c) => {
        const caller = authenticate(c.request);
        if (caller instanceof Response) return caller;
        if (!c.input.ok) return badRequest("Invalid bookmark ID");
        const { id } = c.input.params;
        const bookmark = findById(id);
        if (!bookmark || bookmark.userId !== caller) return notFound("Bookmark not found");
        deleteById(id);
        return new Response(null, { status: 204 });
      },
    }),
  ],
});

serve({ fetch: app.fetch, port: 3000 });
console.log("Running at http://localhost:3000");

Four files, each with a clear responsibility:

  • src/server.ts sets up and starts the app
  • src/data.ts manages the data store
  • src/auth.ts handles authentication
  • src/responses.ts provides error response helpers

CORS

Your API is running at localhost:3000. Your frontend is running at localhost:5173. You open the frontend, it makes a fetch request to the API, and the browser blocks it. Why?

Browsers enforce Cross-Origin Resource Sharing, or CORS. By default, a page at one origin cannot make requests to a different origin. Without CORS headers on your API, the browser refuses to let the frontend read the response.

The cors() function handles this. It returns an object with two pieces: a preflight handler for OPTIONS requests, and a headers function for adding CORS headers to responses. You wire them into your app separately.

Update your setup() call:

import { setup, route, cors } from "@hectoday/http";

const corsConfig = cors({
  origin: "*",
  allowHeaders: ["Content-Type", "Authorization"],
});

const app = setup({
  onRequest: ({ request }) => {
    /* ... same as before ... */
  },
  onResponse: ({ request, response, locals }) => {
    const duration = Date.now() - locals.startTime;
    response.headers.set("X-Request-Id", locals.requestId);
    response.headers.set("X-Response-Time", `${duration}ms`);
    console.log(`<- ${response.status} in ${duration}ms`);
    return corsConfig.headers(request, response);
  },
  routes: [
    corsConfig.preflight(route),
    /* ... same as before ... */
  ],
});

Two things happen here:

  1. corsConfig.preflight(route) registers a catch-all OPTIONS /** route. When the browser sends a preflight request before the real one, this route responds with a 204 No Content and the right CORS headers. You add it to your routes array like any other route.

  2. corsConfig.headers(request, response) adds CORS headers to every response. You call it at the end of onResponse and return its result. It takes the request (to read the Origin header) and the response (to add headers to it), and returns a new response with the CORS headers attached.

Notice that onResponse changed slightly. Instead of returning response directly, we now return corsConfig.headers(request, response). The function also receives request in its arguments, which we were not using before.

Here is what each cors() option does:

  • origin controls which domains can make requests. "*" means any domain. For production, use an array of specific domains like ["https://bookmarks.example.com"].
  • allowHeaders specifies which request headers the client is allowed to send.
  • methods lists which HTTP methods are allowed. Defaults to ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"].
  • exposeHeaders specifies which response headers the browser can read from JavaScript. By default, browsers hide custom headers like X-Request-Id.
  • credentials controls whether cookies and auth headers are allowed. When this is true, origin cannot be "*".
  • maxAge sets how long the browser caches the preflight response, in seconds.

For development, cors({ origin: "*" }) is the simplest option. For production, list your actual frontend domains.


OpenAPI documentation

Right now, the only way to know what our API does is to read the code. That works when you are the only developer, but it breaks down when other people need to use your API. They need documentation.

@hectoday/openapi generates an OpenAPI 3.1 spec from your routes and Zod schemas automatically. Install it:

npm install @hectoday/openapi

The openapi() function takes your routes and a config object, and returns two things: a spec route that serves the OpenAPI JSON, and a docs route that serves an interactive documentation UI. You add both to your routes array.

Here is how to wire it in. First, pull the routes array out into a variable so you can pass it to both openapi() and setup():

import { openapi } from "@hectoday/openapi";

const routes = [
  corsConfig.preflight(route),
  route.get("/health", {
    resolve: () => Response.json({ status: "ok" }),
  }),
  /* ... all your other routes ... */
];

const api = openapi(routes, {
  info: {
    title: "Bookmarks API",
    version: "1.0.0",
    description: "Save and organize your bookmarks.",
  },
  servers: [{ url: "http://localhost:3000", description: "Development" }],
  securitySchemes: {
    bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
  },
  security: [{ bearerAuth: [] }],
});

const app = setup({
  onRequest: ({ request }) => {
    /* ... */
  },
  onResponse: ({ request, response, locals }) => {
    /* ... */
  },
  routes: [...routes, api.spec(route), api.docs(route)],
});

openapi() reads your routes and their Zod schemas, and generates a full OpenAPI 3.1 spec. The request schemas you already wrote for validation do double duty here. They validate at runtime AND generate the spec. One source of truth.

api.spec(route) registers a GET route at /openapi.json (configurable via specPath in the config). api.docs(route) registers a GET route at /docs (configurable via docsPath) that serves a Scalar API reference UI.

Try it:

curl http://localhost:3000/openapi.json | head -20

Visit http://localhost:3000/docs in your browser and you get an interactive documentation page with every endpoint, every schema, and the ability to test each one.

Response schemas

You can add response schemas to your routes for documentation. These are keyed by HTTP status code and are documentation-only. They do not validate outgoing data at runtime:

route.get("/bookmarks/:id", {
  request: { params: z.object({ id: z.uuid() }) },
  response: {
    200: z.object({
      id: z.uuid(),
      url: z.url(),
      title: z.string(),
      tags: z.array(z.string()),
      userId: z.string(),
      createdAt: z.string(),
    }),
    404: z.object({
      error: z.object({ code: z.string(), message: z.string() }),
    }),
  },
  resolve: (c) => { /* ... same handler ... */ },
}),

The request schemas serve double duty: they validate incoming data at runtime AND generate the spec. Change a schema and the docs update automatically. The response schemas just tell the spec what the response looks like for each status code.


Testing

Our API has routes, validation, auth, error handling, CORS, and documentation. But how do we know it actually works? We need tests.

Here is the good news: you do not need to start a server to test. The app has a request method that processes requests through the full lifecycle without any HTTP server, port, or network. You just pass it a path and optional options, and it returns a Response.

Since we already export app from src/server.ts, we can import it directly in our tests. Install vitest:

npm install -D vitest

Writing tests

Create tests/bookmarks.test.ts:

import { describe, test, expect } from "vitest";
import { app } from "../server";

describe("Bookmarks API", () => {
  test("health check is public", async () => {
    const res = await app.request("/health");
    expect(res.status).toBe(200);
  });

  test("bookmarks require auth", async () => {
    const res = await app.request("/bookmarks");
    expect(res.status).toBe(401);
  });

  test("rejects invalid input", async () => {
    const res = await app.request("/bookmarks", {
      method: "POST",
      headers: { Authorization: "Bearer alice" },
      body: { url: "not-a-url" },
    });
    expect(res.status).toBe(400);
  });

  test("creates and retrieves a bookmark", async () => {
    const createRes = await app.request("/bookmarks", {
      method: "POST",
      headers: { Authorization: "Bearer alice" },
      body: { url: "https://example.com", title: "Example" },
    });
    expect(createRes.status).toBe(201);
    const bookmark = (await createRes.json()) as { id: string };

    const getRes = await app.request(`/bookmarks/${bookmark.id}`, {
      headers: { Authorization: "Bearer alice" },
    });
    expect(getRes.status).toBe(200);
  });

  test("includes response headers", async () => {
    const res = await app.request("/health");
    expect(res.headers.get("x-request-id")).toBeDefined();
    expect(res.headers.get("x-response-time")).toMatch(/ms$/);
  });

  test("returns 404 for unknown routes", async () => {
    const res = await app.request("/nonexistent");
    expect(res.status).toBe(404);
  });

  test("user cannot access another user's bookmark", async () => {
    const createRes = await app.request("/bookmarks", {
      method: "POST",
      headers: { Authorization: "Bearer alice" },
      body: { url: "https://private.com", title: "Private" },
    });
    const bookmark = (await createRes.json()) as { id: string };

    const res = await app.request(`/bookmarks/${bookmark.id}`, {
      headers: { Authorization: "Bearer bob" },
    });
    expect(res.status).toBe(403);
  });
});

app.request(path, options?) is the testing API. The first argument is the path. The second is an optional object with method, headers, body, and query. It defaults to a GET request. When you pass a body, it automatically serializes it as JSON and sets the Content-Type header.

Everything runs through the real code path: onRequest, route matching, validation, the handler, onResponse. No mocking, no stubs, no test doubles.

Run the tests:

npx vitest run

Organizing your project

If you have been coding along, your project now looks like this:

src/
  server.ts        setup(), serve(), and all routes
  data.ts          in-memory data store
  auth.ts          authenticate()
  responses.ts     notFound(), forbidden(), badRequest()
tests/
  bookmarks.test.ts

As your API grows, you might want to split routes into separate files. Routes are just arrays, so you can export them and spread them into setup():

// src/routes/bookmarks.ts
import { route } from "@hectoday/http";

export const bookmarkRoutes = [
  route.get("/bookmarks", {
    resolve: (c) => {
      /* ... */
    },
  }),
  route.post("/bookmarks", {
    resolve: (c) => {
      /* ... */
    },
  }),
];
// src/server.ts
import { setup } from "@hectoday/http";
import { bookmarkRoutes } from "./routes/bookmarks.js";
import { systemRoutes } from "./routes/system.js";

export const app = setup({
  routes: [...systemRoutes, ...bookmarkRoutes],
});

Each file is independent. You can test route files in isolation, move them around, or add new ones without touching the rest of the app.


Every feature at a glance

FeatureWhat it does
setup()Creates the application
route.get/post/put/patch/delete()Defines endpoints
resolve: (c) => ResponseRoute handler, always returns a Response
c.requestWeb Standard Request object
c.input.okWhether validation passed
c.input.bodyValidated request body
c.input.queryValidated query parameters
c.input.paramsPath parameters
c.input.issuesValidation error details
c.localsData from onRequest
request: { body, query, params }Zod validation schemas
onRequestRuns before every handler (request IDs, timing)
onResponseRuns after every handler (headers, logging)
onErrorSafety net for unexpected crashes (bugs only)
onNotFoundCustom 404 response
authenticate() helperReturns string | Response, route checks and returns early
notFound(), forbidden() helpersReturn error Responses, no throws
cors()Cross-origin request handling
openapi()OpenAPI 3.1 spec generation
response: { 200: schema, ... }Response schemas for docs (keyed by status code)
app.request(path, options?)Test without a server
Response.json()JSON response
new Response(null, { status: 204 })Empty response

For deeper coverage of each feature, check out the reference docs for @hectoday/http and @hectoday/openapi.