Hooks: the three extension points

Hectoday HTTP has exactly three hooks. Not middleware chains. Not plugins. Three specific extension points with clear jobs.

This chapter explains what each hook does, when it runs, what it can and cannot do, and how to use them effectively.

The three hooks

setup({
  handlers: [...],
  
  onRequest: ({ request }) => {
    // Runs BEFORE routing
    // Returns: locals to merge into context
  },
  
  onResponse: ({ context, response }) => {
    // Runs AFTER handler succeeds
    // Returns: modified (or original) Response
  },
  
  onError: ({ error, context }) => {
    // Runs when handler THROWS
    // Returns: error Response to send
  }
});

Key insight: These three points cover the entire request lifecycle. Nothing happens outside them.

onRequest: Before routing

onRequest runs before routing begins. It receives the raw Request and returns locals.

When it runs

1. Request arrives
2. onRequest runs ← YOU ARE HERE
3. Route matching
4. Guards run
5. Handler runs
6. Response returns

It runs once per request, before any routing logic.

What it receives

onRequest: (info) => {
  const { request } = info;
  
  // request: The standard Web Request object
  request.method    // "GET", "POST", etc.
  request.url       // Full URL
  request.headers   // Headers object
  request.body      // ReadableStream | null
}

That’s all. No route params (routing hasn’t happened). No context (not built yet). Just the raw request.

What it returns

Either void or Record<string, unknown> (or Promise of either):

// No locals
onRequest: ({ request }) => {
  console.log(`${request.method} ${request.url}`);
  // Returns void implicitly
}

// Add locals
onRequest: ({ request }) => {
  return {
    requestId: crypto.randomUUID(),
    timestamp: Date.now()
  };
}

// Async
onRequest: async ({ request }) => {
  const session = await getSession(request);
  return { session };
}

These locals merge into every handler’s context:

route.get("/test", {
  resolve: (c) => {
    c.locals.requestId // Available!
    c.locals.timestamp // Available!
    c.locals.session   // Available!
  }
})

What it cannot do

If you need to deny requests, use a guard instead:

// ❌ Wrong: trying to deny in onRequest
onRequest: ({ request }) => {
  if (!request.headers.get("x-api-key")) {
    // Can't return Response here
  }
}

// ✓ Right: deny in a guard
const requireApiKey: GuardFn = (c) => {
  if (!c.request.headers.get("x-api-key")) {
    return {
      deny: Response.json({ error: "Missing API key" }, { status: 401 })
    };
  }
  return { allow: true };
};

Common patterns

Request ID tracking:

onRequest: ({ request }) => {
  const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
  return { requestId };
}

Request logging:

onRequest: ({ request }) => {
  const start = performance.now();
  console.log(`${request.method} ${request.url}`);
  return { start };
}

Session loading:

onRequest: async ({ request }) => {
  const sessionId = request.headers.get("cookie")?.match(/session=([^;]+)/)?.[1];
  if (!sessionId) return {};
  
  const session = await loadSession(sessionId);
  return { session };
}

Environment injection:

// Cloudflare Workers
export default {
  fetch: (request, env, ctx) => {
    return setup({
      onRequest: () => ({ env, ctx }),
      handlers: [...]
    }).fetch(request);
  }
};

onResponse: After handler

onResponse runs after the handler succeeds. It receives the context and response, returns a (possibly modified) response.

When it runs

1. Request arrives
2. onRequest runs
3. Route matching
4. Guards run
5. Handler runs
6. onResponse runs ← YOU ARE HERE
7. Response sent

It runs once per request, after handler returns successfully.

What it receives

onResponse: (info) => {
  const { context, response } = info;
  
  // context: Full request context from the handler
  context.request  // Original Request
  context.locals   // All accumulated locals
  context.raw      // Raw params, query, body
  context.input    // Validation results
  
  // response: The Response from the handler
  response.status  // Status code
  response.headers // Headers
  response.body    // Body stream
}

Full context. Everything the handler had access to.

What it returns

A Response (or Promise of Response):

// Return the response unmodified
onResponse: ({ response }) => response

// Add headers
onResponse: ({ context, response }) => {
  const headers = new Headers(response.headers);
  headers.set("x-request-id", context.locals.requestId);
  
  return new Response(response.body, {
    status: response.status,
    headers
  });
}

// Async processing
onResponse: async ({ context, response }) => {
  await logResponse(context, response);
  return response;
}

What it cannot do

Cannot run if handler throws:

route.get("/fail", {
  resolve: () => {
    throw new Error("Something broke");
  }
})

// onResponse DOES NOT RUN
// Error goes to onError instead

If the handler throws, onResponse is skipped and onError runs.

Cannot run if guard denies:

route.get("/protected", {
  guards: [(c) => ({ deny: new Response("No", { status: 403 }) })],
  resolve: () => new Response("Yes")
})

// onResponse DOES NOT RUN
// Guard denial is returned directly

onResponse only runs when handler succeeds.

Common patterns

Add response headers:

onResponse: ({ context, response }) => {
  const headers = new Headers(response.headers);
  headers.set("x-request-id", context.locals.requestId);
  headers.set("x-response-time", `${Date.now() - context.locals.start}ms`);
  
  return new Response(response.body, {
    status: response.status,
    headers
  });
}

CORS headers:

onResponse: ({ response }) => {
  const headers = new Headers(response.headers);
  headers.set("access-control-allow-origin", "*");
  headers.set("access-control-allow-methods", "GET, POST, PUT, DELETE");
  
  return new Response(response.body, {
    status: response.status,
    headers
  });
}

Response logging:

onResponse: ({ context, response }) => {
  const duration = Date.now() - context.locals.start;
  console.log(`${context.request.method} ${context.request.url} ${response.status} (${duration}ms)`);
  return response;
}

Content transformation:

onResponse: async ({ response }) => {
  if (response.headers.get("content-type")?.includes("application/json")) {
    const data = await response.json();
    const wrapped = { success: true, data };
    return Response.json(wrapped, { status: response.status });
  }
  return response;
}

onError: When handler throws

onError runs when a handler throws an exception. It receives the error and context, returns an error response.

When it runs

1. Request arrives
2. onRequest runs
3. Route matching
4. Guards run (one throws)
5. Handler runs (or throws)
6. onError runs ← YOU ARE HERE
7. Error response sent

It runs only when something throws. Not for explicit error responses.

What it receives

onError: (info) => {
  const { error, context } = info;
  
  // error: The thrown value (unknown type)
  error // Could be Error, string, object, anything
  
  // context: Minimal context (may be incomplete)
  context.request  // Always available
  context.locals   // May be partial
  context.raw      // May be missing
  context.input    // May be missing
}

The error is unknown, could be anything. The context may be incomplete if error happened early.

What it returns

A Response (or Promise of Response):

// Simple error response
onError: ({ error }) => {
  console.error("Unexpected error:", error);
  return Response.json(
    { error: "Internal Server Error" },
    { status: 500 }
  );
}

// Typed error handling
onError: ({ error, context }) => {
  if (error instanceof ValidationError) {
    return Response.json(
      { error: error.message, issues: error.issues },
      { status: 400 }
    );
  }
  
  if (error instanceof AuthError) {
    return Response.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }
  
  // Unknown error
  console.error("Unexpected error:", error);
  return Response.json(
    { error: "Internal Server Error" },
    { status: 500 }
  );
}

// With context
onError: ({ error, context }) => {
  console.error("Error:", {
    error,
    method: context.request.method,
    url: context.request.url,
    userId: context.locals.userId
  });
  
  return Response.json(
    { error: "Internal Server Error" },
    { status: 500 }
  );
}

What throws are caught

Handlers:

route.get("/test", {
  resolve: () => {
    throw new Error("Handler error"); // → onError
  }
})

Guards:

route.get("/test", {
  guards: [(c) => {
    throw new Error("Guard error"); // → onError
  }],
  resolve: () => new Response("OK")
})

Async errors:

route.get("/test", {
  resolve: async () => {
    await fetch("https://broken.com"); // Throws → onError
  }
})

What doesn’t throw

Explicit Response returns:

route.get("/test", {
  resolve: () => {
    // This is NOT an error, onError doesn't run
    return Response.json({ error: "Not found" }, { status: 404 });
  }
})

Guard denials:

route.get("/test", {
  guards: [(c) => ({
    // This is NOT an error, onError doesn't run
    deny: Response.json({ error: "Forbidden" }, { status: 403 })
  })],
  resolve: () => new Response("OK")
})

onError only catches exceptions, not explicit error responses.

Common patterns

Centralized error logging:

onError: ({ error, context }) => {
  // Log to monitoring service
  await logError({
    error,
    method: context.request.method,
    url: context.request.url,
    userId: context.locals.userId,
    timestamp: Date.now()
  });
  
  return Response.json(
    { error: "Internal Server Error" },
    { status: 500 }
  );
}

Custom error types:

class AppError extends Error {
  constructor(message: string, public status: number) {
    super(message);
  }
}

onError: ({ error }) => {
  if (error instanceof AppError) {
    return Response.json(
      { error: error.message },
      { status: error.status }
    );
  }
  
  console.error("Unexpected error:", error);
  return Response.json(
    { error: "Internal Server Error" },
    { status: 500 }
  );
}

Development vs production:

onError: ({ error }) => {
  const isDev = Deno.env.get("ENV") === "development";
  
  if (isDev) {
    // Expose full error in dev
    return Response.json({
      error: error instanceof Error ? error.message : String(error),
      stack: error instanceof Error ? error.stack : undefined
    }, { status: 500 });
  }
  
  // Hide details in production
  console.error("Production error:", error);
  return Response.json(
    { error: "Internal Server Error" },
    { status: 500 }
  );
}

Parameter styles: two ways to write hooks

All hooks receive a single info object. You can use it two ways:

Destructured (Concise)

Most examples use destructuring for brevity:

onRequest: ({ request }) => {
  return { requestId: crypto.randomUUID() };
}

onResponse: ({ context, response }) => {
  const headers = new Headers(response.headers);
  headers.set("x-request-id", context.locals.requestId);
  return new Response(response.body, { status: response.status, headers });
}

onError: ({ error, context }) => {
  console.error("Error:", error);
  return Response.json({ error: "Internal Error" }, { status: 500 });
}

When to use: You want concise code and only need specific properties.

Named parameter (explicit)

You can also use the named parameter directly:

onRequest: (info) => {
  const { request } = info;
  console.log("Processing request:", request.url);
  return { requestId: crypto.randomUUID() };
}

onResponse: (info) => {
  const { context, response } = info;
  console.log(`Response ${response.status} for ${context.request.url}`);
  return response;
}

onError: (info) => {
  const { error, context } = info;
  console.error(`Error processing ${context.request.url}:`, error);
  return Response.json({ error: "Internal Error" }, { status: 500 });
}

When to use: You want explicit parameter names or need autocomplete to discover what’s available.

Partial destructuring

Only destructure what you need:

// Only need response
onResponse: ({ response }) => {
  const headers = new Headers(response.headers);
  headers.set("x-powered-by", "hectoday");
  return new Response(response.body, { status: response.status, headers });
}

// Only need error
onError: ({ error }) => {
  console.error(error);
  return Response.json({ error: "Internal Error" }, { status: 500 });
}

Both styles work identically. Choose what feels clearest for your code.

Hook execution order

Hooks run in a specific order:

Happy path (no errors)

Request

onRequest

Route Matching

Guards

Handler

onResponse ← Always runs if handler succeeds

Response

Error path (handler throws)

Request

onRequest

Route Matching

Guards

Handler (throws)

onError ← Runs instead of onResponse

Error Response

Guard denial path

Request

onRequest

Route Matching

Guards (deny)

Response ← Guard response returned directly, no onResponse

Key rule: onResponse and onError are mutually exclusive. One or the other, never both.

Hooks are optional

All three hooks are optional:

// No hooks at all
setup({
  handlers: [...]
});

// Just onRequest
setup({
  handlers: [...],
  onRequest: ({ request }) => ({ requestId: crypto.randomUUID() })
});

// Just onError (use default for others)
setup({
  handlers: [...],
  onError: ({ error }) => {
    console.error(error);
    return Response.json({ error: "Internal Error" }, { status: 500 });
  }
});

// All three
setup({
  handlers: [...],
  onRequest: ({ request }) => ({ requestId: crypto.randomUUID() }),
  onResponse: ({ context, response }) => addHeaders(context, response),
  onError: ({ error, context }) => handleError(error, context)
});

If you don’t provide a hook, default behavior applies:

Hooks vs guards vs handlers

When to use each:

Use onRequest for:

Use guards for:

Use onResponse for:

Use onError for:

Use handlers for:

Why three hooks, not middleware chains?

Middleware chains are implicit and unpredictable:

// Middleware: What order? What runs when?
app.use(logger);
app.use(auth);
app.use(cors);
app.get("/test", handler);
// Which middleware can short-circuit?
// Which run on success vs error?
// How do they compose?

Hooks are explicit and predictable:

setup({
  onRequest: logger,        // Always runs first
  handlers: [               // Then routing
    route.get("/test", {
      guards: [auth],        // Then guards
      resolve: handler       // Then handler
    })
  ],
  onResponse: cors,          // Then onResponse (if success)
  onError: errorHandler      // Or onError (if throw)
});

Every step is visible. Every path is clear. No magic.

Summary

Three hooks:

  1. onRequest - Before routing, add locals
  2. onResponse - After handler, modify response
  3. onError - When throw, return error response

Three rules:

  1. Hooks are optional
  2. onResponse XOR onError (never both)
  3. Everything is explicit

Three questions:

  1. Does every request need it? → onRequest
  2. Does every response need it? → onResponse
  3. Does every error need it? → onError

Next: Errors are responses - how to handle errors explicitly without throwing.