The request lifecycle (fully explicit)

You’ve seen the pieces. Now see how they fit together.

Every request in Hectoday HTTP follows the same path. Every step is visible. Nothing happens automatically.

The full path of a request

Here’s the complete lifecycle:

1. Request arrives

2. onRequest hook (optional)
   → Produces initial locals

3. Route matching
   → Finds handler by method + path
   → 404 if no match

4. Extract raw inputs
   → params from path
   → query from search string
   → body (if schema defined)

5. Validate inputs (if schemas defined)
   → Run validator on params/query/body
   → Produce c.input (ok or not ok)

6. Run guards (in order)
   → Each guard: allow or deny
   → First deny ends request
   → Each allow can add locals

7. Run handler
   → Receives context with all data
   → Must return Response

8. onResponse hook (optional)
   → Can modify response

9. Send response to client

Every request follows this path. No exceptions. No shortcuts. No hidden branches.

Read

The framework extracts raw data from the request:

route.post("/orgs/:orgId/users", {
  resolve: (c) => {
    // Framework already extracted these
    c.raw.params.orgId        // From path: /orgs/:orgId/users
    c.raw.query.notify        // From search: ?notify=true
    c.raw.body                // From request body (if parsed)
    
    // Original request is always available
    c.request.method          // "POST"
    c.request.url             // Full URL
    c.request.headers         // All headers
  }
})

What the framework does:

What the framework doesn’t do:

Validate

If you defined schemas, the framework runs validation:

route.post("/orgs/:orgId/users", {
  request: {
    params: z.object({
      orgId: z.string().uuid()
    }),
    query: z.object({
      notify: z.enum(["true", "false"]).transform(v => v === "true")
    }),
    body: z.object({
      name: z.string(),
      email: z.string().email()
    })
  },
  resolve: (c) => {
    // Framework already ran validation
    c.input.ok // true or false
    
    if (!c.input.ok) {
      // Framework populated these
      c.input.failed   // ["params"] or ["query", "body"] etc.
      c.input.issues   // Array of validation issues
      c.input.received // Raw values that failed
      
      // YOU decide what this means
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    
    // Framework populated validated, typed data
    c.input.params.orgId  // string (UUID format)
    c.input.query.notify  // boolean
    c.input.body.name     // string
    c.input.body.email    // string (valid email)
  }
})

What the framework does:

What the framework doesn’t do:

You decide what validation failures mean.

Guard

Guards run in order. Each can allow or deny:

const requireAuth: GuardFn = (c) => {
  const token = c.request.headers.get("authorization");
  
  if (!token) {
    return { deny: Response.json({ error: "Unauthorized" }, { status: 401 }) };
  }
  
  const user = verifyToken(token);
  
  if (!user) {
    return { deny: Response.json({ error: "Invalid token" }, { status: 401 }) };
  }
  
  return { allow: true, locals: { user } };
};

const requireAdmin: GuardFn = (c) => {
  const user = c.locals.user; // From requireAuth
  
  if (user.role !== "admin") {
    return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) };
  }
  
  return { allow: true };
};

route.delete("/users/:id", {
  guards: [requireAuth, requireAdmin],
  resolve: (c) => {
    // Only runs if both guards allowed
    const user = c.locals.user; // Available because requireAuth allowed
    return new Response(null, { status: 204 });
  }
})

What the framework does:

What the framework doesn’t do:

You decide what must pass for a request to continue.

Handle

The handler receives context and returns a Response:

route.post("/users", {
  request: {
    body: z.object({
      name: z.string(),
      email: z.string().email()
    })
  },
  guards: [requireAuth],
  resolve: async (c) => {
    // All guards passed (or we wouldn't be here)
    // Validation passed (or c.input.ok would be false)
    
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    
    // c.input.body is typed and validated
    const { name, email } = c.input.body;
    
    // Business logic
    const existingUser = await db.users.findByEmail(email);
    
    if (existingUser) {
      return Response.json(
        { error: "Email already exists" },
        { status: 409 } // Conflict
      );
    }
    
    const user = await db.users.create({ name, email });
    
    return Response.json(user, { status: 201 });
  }
})

What the framework does:

What the framework doesn’t do:

You decide everything about the response.

Respond

The response goes back to the client:

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

What the framework does:

What the framework doesn’t do:

The Response you return is the Response the client receives. What you write is what they get.

Complete example: tracing a request

Let’s trace a complete request through the system:

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

// Guards
const requireAuth: GuardFn = (c) => {
  const token = c.request.headers.get("authorization");
  
  if (!token) {
    return { deny: Response.json({ error: "Unauthorized" }, { status: 401 }) };
  }
  
  const user = verifyToken(token);
  
  if (!user) {
    return { deny: Response.json({ error: "Invalid token" }, { status: 401 }) };
  }
  
  return { allow: true, locals: { user, userId: user.id } };
};

// Route
const createUserRoute = route.post("/orgs/:orgId/users", {
  request: {
    params: z.object({
      orgId: z.string().uuid()
    }),
    body: z.object({
      name: z.string().min(1).max(100),
      email: z.string().email()
    })
  },
  guards: [requireAuth],
  resolve: async (c) => {
    // Check validation
    if (!c.input.ok) {
      return Response.json(
        { error: "Invalid input", issues: c.input.issues },
        { status: 400 }
      );
    }
    
    const { orgId } = c.input.params;
    const { name, email } = c.input.body;
    const userId = c.locals.userId;
    
    // Business logic
    const org = await db.orgs.get(orgId);
    
    if (!org) {
      return Response.json({ error: "Organization not found" }, { status: 404 });
    }
    
    const canCreate = await db.orgs.canUserCreateMembers(orgId, userId);
    
    if (!canCreate) {
      return Response.json({ error: "Forbidden" }, { status: 403 });
    }
    
    const existingUser = await db.users.findByEmail(email);
    
    if (existingUser) {
      return Response.json({ error: "Email already in use" }, { status: 409 });
    }
    
    const newUser = await db.users.create({
      orgId,
      name,
      email,
      createdBy: userId
    });
    
    return Response.json(newUser, { status: 201 });
  }
});

// Setup
const app = setup({
  handlers: [createUserRoute],
  
  onRequest: ({ request }) => {
    return { requestId: crypto.randomUUID(), startTime: Date.now() };
  },
  
  onResponse: ({ context, response }) => {
    const duration = Date.now() - (context.locals.startTime as number);
    console.log({
      requestId: context.locals.requestId,
      method: context.request.method,
      path: new URL(context.request.url).pathname,
      status: response.status,
      duration
    });
    
    const headers = new Headers(response.headers);
    headers.set("X-Request-Id", String(context.locals.requestId));
    
    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers
    });
  },
  
  onError: ({ error, context }) => {
    console.error("Unexpected error:", {
      requestId: context.locals.requestId,
      error,
      path: new URL(context.request.url).pathname
    });
    
    return Response.json(
      { error: "Internal server error", requestId: context.locals.requestId },
      { status: 500 }
    );
  }
});

Deno.serve(app.fetch);

Successful request

POST /orgs/123e4567-e89b-12d3-a456-426614174000/users
Authorization: Bearer valid-token-here
Content-Type: application/json

{"name":"Alice","email":"[email protected]"}

The path:

1. Request arrives
   → POST /orgs/123e4567-e89b-12d3-a456-426614174000/users
   
2. onRequest runs
   → Adds: { requestId: "abc-123", startTime: 1234567890 }
   
3. Route matches
   → POST /orgs/:orgId/users matched
   
4. Extract raw inputs
   → c.raw.params.orgId = "123e4567-e89b-12d3-a456-426614174000"
   → c.raw.body = { name: "Alice", email: "[email protected]" }
   
5. Validate
   → params schema: ✓ valid UUID
   → body schema: ✓ valid name and email
   → c.input.ok = true
   
6. Guards
   → requireAuth runs
   → Token is valid
   → Returns: { allow: true, locals: { user, userId: "user-123" } }
   → c.locals = { requestId: "abc-123", startTime: 1234567890, user: {...}, userId: "user-123" }
   
7. Handler runs
   → c.input.ok is true ✓
   → Check org exists ✓
   → Check permissions ✓
   → Check email not in use ✓
   → Create user
   → Return: Response.json(newUser, { status: 201 })
   
8. onResponse runs
   → Logs request details
   → Adds X-Request-Id header
   → Returns modified Response
   
9. Client receives
   → 201 Created
   → X-Request-Id: abc-123
   → Body: { id: "...", name: "Alice", email: "[email protected]" }

Failed request (invalid input)

POST /orgs/not-a-uuid/users
Authorization: Bearer valid-token-here
Content-Type: application/json

{"name":"","email":"not-an-email"}

The path:

1-4. Same as above
   
5. Validate
   → params schema: ✗ "not-a-uuid" is not a valid UUID
   → body schema: ✗ name is too short, email is invalid
   → c.input.ok = false
   → c.input.issues = [
       { part: "params", path: ["orgId"], message: "Invalid UUID" },
       { part: "body", path: ["name"], message: "String must contain at least 1 character" },
       { part: "body", path: ["email"], message: "Invalid email" }
     ]
   
6. Guards
   → requireAuth runs (validation doesn't stop guards)
   → Token is valid
   → Returns: { allow: true, locals: { user, userId: "user-123" } }
   
7. Handler runs
   → c.input.ok is false ✗
   → Returns: Response.json({ error: "Invalid input", issues: [...] }, { status: 400 })
   → Handler returns early (business logic never runs)
   
8. onResponse runs
   → Logs request details
   → Adds X-Request-Id header
   
9. Client receives
   → 400 Bad Request
   → X-Request-Id: abc-123
   → Body: { error: "Invalid input", issues: [...] }

Failed request (no auth)

POST /orgs/123e4567-e89b-12d3-a456-426614174000/users
Content-Type: application/json

{"name":"Alice","email":"[email protected]"}

The path:

1-5. Same as successful request
   → Validation passes
   
6. Guards
   → requireAuth runs
   → No Authorization header
   → Returns: { deny: Response.json({ error: "Unauthorized" }, { status: 401 }) }
   → REQUEST ENDS HERE
   
7. Handler NEVER RUNS
   
8. onResponse runs
   → Logs request details
   → Adds X-Request-Id header
   
9. Client receives
   → 401 Unauthorized
   → X-Request-Id: abc-123
   → Body: { error: "Unauthorized" }

Notice: The handler never ran. The guard denied, request ended.

Nothing happens automatically

This is the key principle: the framework does nothing you didn’t ask for.

No fallback behavior

No automatic 400 on validation failure:

route.post("/users", {
  request: { body: schema },
  resolve: (c) => {
    // Framework did NOT return 400 automatically
    // YOU must check and decide
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }
    
    // Continue...
  }
})

No automatic 401 on missing auth:

route.get("/protected", {
  resolve: (c) => {
    const token = c.request.headers.get("authorization");
    
    // Framework did NOT check auth
    // Framework did NOT return 401
    // YOU must check and decide
    if (!token) {
      return Response.json({ error: "Unauthorized" }, { status: 401 });
    }
    
    // Continue...
  }
})

Use guards for this:

route.get("/protected", {
  guards: [requireAuth], // Explicit
  resolve: (c) => {
    // Auth was checked (or we wouldn't be here)
  }
})

No automatic 500 on errors:

Errors that escape handlers go to onError (if defined) or default error handler:

route.get("/users", {
  resolve: async (c) => {
    // If this throws, goes to onError
    const users = await db.users.getAll();
    return Response.json(users);
  }
})

But for expected errors, return explicitly:

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

No hidden defaults

No default headers:

// Framework does NOT add:
// - CORS headers
// - Cache-Control headers
// - Content-Security-Policy headers
// - Any other headers

// YOU add what you need:
return new Response(JSON.stringify(data), {
  headers: {
    "Content-Type": "application/json",
    "Cache-Control": "max-age=3600",
    "Access-Control-Allow-Origin": "*"
  }
});

No default error format:

// Framework does NOT enforce error format
// YOU choose:

// Option 1: Simple
{ error: "Not found" }

// Option 2: Detailed
{ error: { message: "Not found", code: "USER_NOT_FOUND" } }

// Option 3: JSON:API
{ errors: [{ status: "404", title: "Not found", detail: "..." }] }

// Option 4: Problem Details (RFC 7807)
{ type: "...", title: "...", status: 404, detail: "..." }

No default content negotiation:

// Framework does NOT check Accept header
// YOU check if you care:

route.get("/users/:id", {
  resolve: async (c) => {
    const user = await db.users.get(c.raw.params.id);
    
    if (!user) {
      return Response.json({ error: "Not found" }, { status: 404 });
    }
    
    const accept = c.request.headers.get("accept");
    
    if (accept === "application/xml") {
      return new Response(toXML(user), {
        headers: { "Content-Type": "application/xml" }
      });
    }
    
    return Response.json(user);
  }
})

No default logging:

// Framework does NOT log:
// - Request arrivals
// - Response statuses
// - Errors
// - Durations

// YOU log in hooks:
const app = setup({
  handlers: [...],
  
  onRequest: ({ request }) => {
    console.log(`${request.method} ${new URL(request.url).pathname}`);
    return { startTime: Date.now() };
  },
  
  onResponse: ({ context, response }) => {
    const duration = Date.now() - (context.locals.startTime as number);
    console.log(`${response.status} (${duration}ms)`);
    return response;
  },
  
  onError: ({ error, context }) => {
    console.error(`${error}`);
    return Response.json({ error: "Internal error" }, { status: 500 });
  }
});

What this means

You can trace every request by reading code:

  1. Look at route definition → see guards, schemas, handler
  2. Read guards → see exactly what might deny
  3. Read handler → see exactly what responses can return
  4. Read hooks → see what side effects happen

No magic. No hidden behavior. Just explicit code.


Next: Hooks: the three extension points - understanding onRequest, onResponse, and onError in depth.