Your first server

You understand the mental model. You know the Web standards. Now let’s build a server.

A server is just a function

At its core, every Hectoday HTTP server is a function: Request → Response.

Here’s the smallest possible server:

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

const app = setup({
  handlers: [
    route.get("/", {
      resolve: () => new Response("Hello World")
    })
  ]
});

Deno.serve(app.fetch);

Let’s break this down:

The setup

const app = setup({
  handlers: [/* routes go here */]
});

setup() takes a configuration and returns an object with a fetch method. That fetch method is your server function: Request → Response.

The route

route.get("/", {
  resolve: () => new Response("Hello World")
})

route.get() creates a route descriptor:

The server

Deno.serve(app.fetch);

Pass the fetch function to your runtime’s server. It works with:

That’s it. Every Hectoday HTTP server follows this pattern:

  1. Define routes
  2. Pass them to setup()
  3. Pass app.fetch to your runtime

The handler signature

Every handler receives context and returns a Response.

What you receive: Context

route.get("/users/:id", {
  resolve: (c) => {
    // c is the context
    // What's inside?
  }
})

The context object (c) contains:

interface Context {
  request: Request;           // The original Web standard Request
  raw: RawValues;            // Extracted inputs (params, query, body)
  input: InputState;         // Validation results (ok or not ok)
  locals: Record<string, unknown>;  // Request-scoped data
}

c.request - The original request

route.get("/hello", {
  resolve: (c) => {
    console.log(c.request.method);  // "GET"
    console.log(c.request.url);     // "https://example.com/hello"
    console.log(c.request.headers.get("user-agent")); // Browser info
    
    return new Response("Hello");
  }
})

This is the standard Fetch API Request. No wrapper. Use it directly.

c.raw - Extracted inputs

Hectoday HTTP extracts common values for you:

route.get("/users/:id", {
  resolve: (c) => {
    const id = c.raw.params.id;  // Path parameter
    return Response.json({ id });
  }
})
route.get("/search", {
  resolve: (c) => {
    const query = c.raw.query.q;     // Query parameter
    const page = c.raw.query.page;   // Another query parameter
    return Response.json({ query, page });
  }
})
route.post("/users", {
  resolve: async (c) => {
    const body = c.raw.body;  // Parsed body (if you define body schema)
    return Response.json({ received: body });
  }
})

Important: c.raw values are not validated. They’re just extracted. Use them carefully or validate them first.

c.input - Validation results

When you define schemas, c.input tells you if validation passed:

route.post("/users", {
  request: {
    body: z.object({ name: z.string() })
  },
  resolve: (c) => {
    if (!c.input.ok) {
      // Validation failed
      return Response.json(
        { error: c.input.issues },
        { status: 400 }
      );
    }
    
    // Validation passed - c.input.body is typed!
    const name = c.input.body.name; // string
    return Response.json({ name });
  }
})

We’ll cover validation in detail later. For now, know: validation never auto-responds. You check c.input.ok and decide what to do.

c.locals - Request-scoped data

Guards and hooks can attach data to c.locals:

// From a guard or onRequest
{ userId: "123", role: "admin" }

// In your handler
route.get("/profile", {
  resolve: (c) => {
    const userId = c.locals.userId;
    return Response.json({ userId });
  }
})

More on this in the Guards chapter.

Returning a Response

Every handler must return a Response. Not a string. Not an object. A Response.

Simple text

route.get("/hello", {
  resolve: () => new Response("Hello World")
})

JSON

route.get("/user", {
  resolve: () => Response.json({ id: 1, name: "Alice" })
})

With status Code

route.post("/users", {
  resolve: () => Response.json(
    { id: 1, name: "Alice" },
    { status: 201 }  // Created
  )
})

With headers

route.get("/data", {
  resolve: () => new Response(
    JSON.stringify({ data: "value" }),
    {
      status: 200,
      headers: {
        "Content-Type": "application/json",
        "Cache-Control": "max-age=3600"
      }
    }
  )
})

Error responses

route.get("/admin", {
  resolve: (c) => {
    if (!c.locals.isAdmin) {
      return Response.json(
        { error: "Forbidden" },
        { status: 403 }
      );
    }
    
    return Response.json({ secret: "data" });
  }
})

Empty response

route.delete("/users/:id", {
  resolve: (c) => {
    const id = c.raw.params.id;
    // Delete user...
    
    return new Response(null, { status: 204 }); // No Content
  }
})

Async handlers

Most real handlers are async:

route.get("/users/:id", {
  resolve: async (c) => {
    const id = c.raw.params.id;
    const user = await db.users.get(id);
    
    if (!user) {
      return Response.json(
        { error: "User not found" },
        { status: 404 }
      );
    }
    
    return Response.json(user);
  }
})

Multiple returns

You can return from multiple places:

route.get("/users/:id", {
  resolve: async (c) => {
    const id = c.raw.params.id;
    
    // Early return for invalid input
    if (!id || !/^\d+$/.test(id)) {
      return Response.json(
        { error: "Invalid user ID" },
        { status: 400 }
      );
    }
    
    const user = await db.users.get(id);
    
    // Early return for not found
    if (!user) {
      return Response.json(
        { error: "User not found" },
        { status: 404 }
      );
    }
    
    // Success case
    return Response.json(user);
  }
})

Each return is a decision boundary. The request ends there.

When a request ends

This is the most important concept in Hectoday HTTP: knowing when requests end.

Only two ways a request can end

In Hectoday HTTP, requests end in exactly two places:

1. A guard denies

const requireAuth = (c) => {
  const token = c.request.headers.get("authorization");
  
  if (!token) {
    // REQUEST ENDS HERE
    return { deny: Response.json({ error: "Unauthorized" }, { status: 401 }) };
  }
  
  return { allow: true };
};

route.get("/protected", {
  guards: [requireAuth],
  resolve: (c) => {
    // Only runs if guard allowed
    return Response.json({ data: "secret" });
  }
})

If a guard returns { deny: Response }, the request ends. The handler never runs.

2. A handler returns

route.get("/users/:id", {
  resolve: (c) => {
    const id = c.raw.params.id;
    
    if (!id) {
      // REQUEST ENDS HERE
      return Response.json({ error: "Missing ID" }, { status: 400 });
    }
    
    // REQUEST ENDS HERE
    return Response.json({ id });
  }
})

When the handler returns a Response, the request ends.

What CANNOT end requests

These do not end requests:

Validation failures

route.post("/users", {
  request: { body: schema },
  resolve: (c) => {
    // Validation failed - but request continues!
    if (!c.input.ok) {
      // You must explicitly return
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    
    // If you didn't return, you're still here
    return Response.json({ success: true });
  }
})

Validation sets c.input.ok = false. It doesn’t return. You decide what that means.

Throwing errors

route.get("/users", {
  resolve: async (c) => {
    // This throws - goes to onError, not a normal response
    const users = await db.users.getAll(); // Might throw
    
    return Response.json(users);
  }
})

If you throw, the error goes to the global onError handler (if defined). This is for unexpected errors, not normal control flow.

For expected failures, return explicitly:

route.get("/users/:id", {
  resolve: async (c) => {
    const id = c.raw.params.id;
    const user = await db.users.get(id);
    
    // Don't throw - return
    if (!user) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }
    
    return Response.json(user);
  }
})

Why this matters for correctness

When you know exactly where requests can end, you can reason about your code:

route.post("/users", {
  guards: [requireAuth],
  resolve: async (c) => {
    // If we're here, auth passed (or guard would have denied)
    
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
      // ↑ Request ends here
    }
    
    // If we're here, validation passed (or we would have returned)
    
    const user = await createUser(c.input.body);
    
    if (!user) {
      return Response.json({ error: "Creation failed" }, { status: 500 });
      // ↑ Request ends here
    }
    
    // If we're here, creation succeeded
    
    return Response.json(user, { status: 201 });
    // ↑ Request ends here
  }
})

At any point in the handler, you can trace backward:

No hidden branching. No magic returns. Just explicit control flow.

The complete picture

Request arrives

Framework routes to handler

Guards run (might deny → request ends)

Handler runs (must return → request ends)

Response sent

Two decision points. Two ways to end. Everything else is just computation.


Next: Describing facts - how to safely extract and work with request data.