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 returnsIt 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
- Cannot deny requests - no way to return Response
- Cannot short-circuit - always runs fully
- Cannot access route params - routing hasn’t happened yet
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 sentIt 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 insteadIf 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 directlyonResponse 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 sentIt 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
↓
ResponseError path (handler throws)
Request
↓
onRequest
↓
Route Matching
↓
Guards
↓
Handler (throws)
↓
onError ← Runs instead of onResponse
↓
Error ResponseGuard denial path
Request
↓
onRequest
↓
Route Matching
↓
Guards (deny)
↓
Response ← Guard response returned directly, no onResponseKey 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:
onRequest: No locals addedonResponse: Response returned unmodifiedonError: Logs error, returns 500
Hooks vs guards vs handlers
When to use each:
Use onRequest for:
- Request ID generation
- Session loading
- Request logging
- Environment setup
- Anything every request needs before routing
Use guards for:
- Authentication checks
- Authorization decisions
- Request validation that can deny
- Anything that decides if a specific route should run
Use onResponse for:
- Adding response headers
- Response logging
- Response transformation
- Anything that modifies successful responses
Use onError for:
- Centralized error logging
- Error response formatting
- Development vs production error handling
- Anything that handles unexpected exceptions
Use handlers for:
- Business logic
- Explicit error responses (not throws)
- Anything that’s route-specific logic
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:
- onRequest - Before routing, add locals
- onResponse - After handler, modify response
- onError - When throw, return error response
Three rules:
- Hooks are optional
- onResponse XOR onError (never both)
- Everything is explicit
Three questions:
- Does every request need it? →
onRequest - Does every response need it? →
onResponse - Does every error need it? →
onError
Next: Errors are responses - how to handle errors explicitly without throwing.