# Hectoday HTTP - Complete Documentation > A web framework that refuses to make decisions for you. Hectoday HTTP is built on Web Standards (Request/Response) and runs on Deno, Bun, and Cloudflare Workers. --- # PART 1: MENTAL MODELS ================================================================================ ## What this is > Understanding Hectoday HTTP's purpose and philosophy > Source: /docs/what-this-is ================================================================================ Hectoday HTTP is a web framework that refuses to make decisions for you. Most frameworks decide what your HTTP responses mean. They see validation failures and return 400. They see missing auth tokens and return 401. They catch errors and return 500. Hectoday HTTP does none of this. Instead, it **describes facts** and lets **you commit reality**. ## The core idea ```typescript // Hectoday HTTP gives you facts if (!c.input.ok) { // What does this mean? You decide. // 400? 422? 200 with error object? Your call. return Response.json({ error: c.input.issues }, { status: 400 }); } // If you didn't return, you're still here // The framework never returns for you ``` Hectoday HTTP computes: - Raw inputs (params, query, body) - Validation results (ok or not ok) - Guard outcomes (allow or deny) **You decide what these facts mean as HTTP.** ## What problems it solves ### Hidden control flow Most frameworks have invisible branching: ```typescript // In many frameworks, this line might end the request validateBody(schema); // Did we return? Are we still here? Who knows? ``` With Hectoday HTTP, nothing happens unless you cause it: ```typescript // This never returns const result = validator.validate(schema, input); // You're definitely still here if (!result.ok) { return Response.json({ error: result.issues }, { status: 400 }); } ``` ### Implicit meaning Frameworks often assign meaning to code patterns: ```typescript // Does this return 401? 403? Is there a guard running? requireAuth(); // What actually happens here? ``` Hectoday HTTP makes decisions explicit: ```typescript // A guard returns a result const guard = (c) => { if (!c.request.headers.get("authorization")) { return { deny: Response.json({ error: "Unauthorized" }, { status: 401 }) }; } return { allow: true }; }; // You see exactly when and how requests end ``` ### Framework lock-in Most frameworks wrap Web standards: ```typescript // Framework-specific ctx.header("X-Custom", "value"); ctx.body({ data: "..." }); ctx.status(201); ``` Hectoday HTTP uses Fetch API everywhere: ```typescript // Web standard (works in Deno, Bun, Workers, browsers) return new Response(JSON.stringify({ data: "..." }), { status: 201, headers: { "X-Custom": "value" }, }); ``` ## What it intentionally does not solve ### Convenience over Clarity Hectoday HTTP will never auto-return 400 on validation failure. That would be convenient, but it would hide the decision. ### Magic error Handling Hectoday HTTP will never catch handler errors and return 500. You handle errors explicitly or they go to `onError`. ### Opinionated responses Hectoday HTTP will never format error objects for you. JSON:API? Problem Details? Your schema? You choose. ### Middleware chains Hectoday HTTP has no middleware. Instead, it has: - `onRequest` - gathers facts before routing - Guards - make allow/deny decisions - Handlers - return responses Each step has exactly one job. ## Who this is for ### You want explicit control You want to **see** every decision boundary in your code. You don't want the framework guessing what you meant. ### You value Web Standards You want to write code that works across Deno, Bun, Cloudflare Workers, and future runtimes. You don't want to rewrite when platforms change. ### You think in facts and decisions You separate **observing reality** from **committing to outcomes**. You want your framework to match this mental model. ### You're building something that lasts You want code that's still readable in 3 years. You want new team members to understand the request flow by reading handlers, not docs. ## Who this is not for ### You want rapid prototyping If you need to ship fast and don't care about explicitness, use something with more magic. Hectoday HTTP optimizes for clarity over speed of development. ### You want batteries included Hectoday HTTP has no built-in ORM, no template engine, no session middleware. It's a request handler, nothing more. ### You want convention over configuration Hectoday HTTP has almost no conventions. You write explicit code for every case. If you prefer "it just works" magic, this isn't it. ## The philosophy in one sentence **Hectoday HTTP describes what happened. You decide what it means.** --- The framework never makes HTTP decisions. It computes facts about requests. Guards make allow/deny decisions. Handlers commit responses. Everything else is your job. And that's the point. ================================================================================ ## How to read these docs > A guide to understanding Hectoday HTTP's documentation structure > Source: /docs/how-to-read-these-docs ================================================================================ These docs are structured as **concept → practice → reference**. Each chapter builds on the previous one, adding a single idea. You can stop at any point and still understand everything you've read. ## The structure ### Part 1: mental models Before you write code, you need a way to **think about HTTP**. These chapters build a mental model: - How requests and responses work - Why direction and perspective matter - What "facts before decisions" means **Read these even if you're impatient.** The framework's API only makes sense with this model. ### Part 2: core concepts These chapters introduce the framework's primitives: - Handlers (return responses) - Facts (raw inputs, validation) - Guards (allow/deny decisions) Each concept gets its own chapter. **Nothing is assumed.** If you read linearly, you'll never encounter an unexplained term. ### Part 3: composition Once you understand the pieces, you'll see how they compose: - The full request lifecycle - Error handling - Building larger APIs This is where the framework's constraints start to feel like leverage. ### Part 4: real concerns Practical chapters about: - Security - Static files - Runtime differences (Deno vs Bun vs Workers) These apply the mental model to real problems. ### Part 5: reference - Testing strategies - Complete API reference - Philosophy revisited Use these when you need details, not understanding. ## How to Navigate ### If you're exploring Start at **Chapter 1: A Mental Model of HTTP**. Read sequentially. Each chapter is short. ### If you need something specific Jump to the reference (Chapter 14). But if the API feels confusing, back up to the concept chapters. ### If you're experienced Skim **Chapter 1-2** to understand the mental model. Then jump to **Chapter 7: The Request Lifecycle** for the full picture. ### If you're skeptical Read **Chapter 1: A mental model** and **Chapter 15: Philosophy**. If these resonate, read the rest. If not, this framework isn't for you, and that's fine. ## What these docs are not ### Not a tutorial You won't find "build a blog in 10 minutes." These docs explain **how the framework thinks**, not how to accomplish tasks quickly. Once you understand the model, tasks become obvious. ### Not a Cookbook You won't find recipes like "how to handle file uploads" or "how to add CORS." Instead, you'll learn the primitives, and recipes become trivial. (We do provide helpers for common patterns, listed separately at the bottom of the documentation index, but they're documented as **examples of composition**, not magical solutions.) ### Not comprehensive on first read You don't need to read everything to start. **Read until you understand the model**, then write code. Return to the docs when you need specifics. ## Key terms you'll encounter These terms have precise meanings in Hectoday HTTP: **Fact**: Information extracted or computed, but not acted upon. Examples: raw inputs, validation results. **Decision Boundary**: A place where the request can end. Only two exist: guards and handlers. **Guard**: A function that decides whether a request may continue. Returns `{ allow: true }` or `{ deny: Response }`. **Handler**: A function that returns a `Response`. The end of every successful request. **Context (`c`)**: The object passed to guards and handlers. Contains request, raw inputs, validation results, and locals. **Locals**: Request-scoped data accumulated from `onRequest` and guards. Never mutated, always merged forward. ## Reading tips ### Linear first, random later On first read, go **sequentially**. The chapters assume you've seen the previous ones. After you've read through once, use the docs as a reference. Jump around freely. ### Code first, explanation second Each chapter shows code **before** explaining it. Try to understand the code yourself first. Then read the explanation. This mirrors how you'll use the framework: you'll see code, and it should make sense without extensive docs. ### When something feels wrong If an API feels awkward or verbose, **that's often intentional**. The framework optimizes for explicitness, not brevity. If you find yourself wanting magic, re-read **Chapter 9: Composition** and **Chapter 15: Philosophy**. The verbosity often disappears when you compose primitives. ## What success looks like You'll know you understand Hectoday HTTP when: 1. You can **trace a request** from arrival to response by reading handler code 2. You know **exactly** where decisions happen (guards and handlers, nowhere else) 3. You can **predict** whether a line of code might end the request (only `return` statements in guards/handlers) 4. You think of validation as **producing facts**, not controlling flow When these feel natural, you've internalized the model. The rest is just syntax. ## A note on length These docs are **detailed but not long**. Each chapter is short. We prefer: - One idea per chapter - Code before prose - Precision over brevity You can read the core chapters (1-9) in under an hour. But the ideas will change how you think about HTTP. ## Ready? Start with [Chapter 1: A mental model of HTTP](./a-mental-model-of-http). Or jump to [Installation](./installation) if you want to start coding immediately. ================================================================================ ## A mental model of HTTP > Understanding HTTP from first principles > Source: /docs/a-mental-model-of-http ================================================================================ Before you write a single line of code, you need a way to think about HTTP servers. This chapter builds that mental model. No framework concepts yet. Just HTTP itself. ## Every request has a path When a request arrives at your server, it has already traveled: ``` Browser → DNS → Network → Your Server ``` But once it arrives, it travels again **inside your application**: ``` Socket → Parser → Router → Handler → Response ``` This internal path is what you control as a developer. ### What a request is A request is data sent from a client: ```typescript interface Request { method: string; // "GET", "POST", etc. url: string; // "https://example.com/users/123?include=posts" headers: Headers; // Key-value pairs body: ReadableStream | null; // Optional payload } ``` That's it. Nothing more, nothing less. The request doesn't "know" if it's authorized. It doesn't "know" if its data is valid. It's just bytes that need interpretation. ### What a response is A response is data sent back to the client: ```typescript interface Response { status: number; // 200, 404, 500, etc. statusText: string; // "OK", "Not Found", etc. headers: Headers; // Key-value pairs body: ReadableStream | null; // Optional payload } ``` Every request must produce exactly one response. Not zero. Not two. One. ### Why servers are just functions over time At the lowest level, a server is: ```typescript function server(request: Request): Response { // Do something return response; } ``` But in reality, servers handle many requests over time: ```typescript // Request 1 arrives server(request1) // → response1 // Request 2 arrives (before request1 finishes) server(request2) // → response2 // Request 1 completes // → response1 sent // Request 2 completes // → response2 sent ``` Each request is independent. They might interleave. But each one follows its own path from arrival to response. **This is the fundamental model**: requests come in, responses go out, and each request has its own isolated path through your code. ## Inbound request → outbound response Direction matters in HTTP. Understanding the flow from input to output is essential. ### The directionality of HTTP ``` CLIENT SERVER | | | --- Request (inbound) ---> | | | | [processing] | | | <-- Response (outbound) --- | | | ``` **Inbound**: Client to server. You receive this. You don't control its contents. **Outbound**: Server to client. You create this. You control every byte. ### Why this perspective matters When a request arrives, you're in **reactive mode**: ```typescript // You didn't choose this method request.method // "POST" // You didn't choose this URL request.url // "https://example.com/users/999999999" // You didn't choose these headers request.headers.get("content-type") // "application/xml" // You didn't choose this body request.body // ??? ``` Everything about the request is **given to you**. You must: 1. Read what exists 2. Interpret what it means 3. Decide what to do 4. Construct a response You're not calling APIs. You're not fetching data. You're **receiving** input and **producing** output. ### The asymmetry ```typescript // Inbound: you must handle anything request.url // Could be "/users/1" or "/🚀/💥" or "/../../../etc/passwd" request.headers.get("content-type") // Could be missing, malformed, or lies // Outbound: you control everything return new Response( JSON.stringify({ id: 1, name: "Alice" }), { status: 200, headers: { "Content-Type": "application/json" } } ); ``` **Inbound is hostile.** You must validate everything. Assume nothing. **Outbound is safe.** You construct exactly what you want to send. ### Why naming this changes design Most frameworks blur this boundary. They make inbound data feel safe: ```typescript // Does this exist? Is it valid? Who knows? const userId = ctx.params.id; // What happens if this fails? Where does it return? const user = await db.users.get(userId); ``` When you think in terms of **inbound → processing → outbound**, you see the phases: ```typescript // Phase 1: Inbound (receive) const userIdRaw = c.raw.params.id; // string | undefined // Phase 2: Validate (interpret) if (!userIdRaw || !/^\d+$/.test(userIdRaw)) { // Phase 3: Outbound (respond) return Response.json({ error: "Invalid user ID" }, { status: 400 }); } // Phase 2: Process (decide) const userId = parseInt(userIdRaw); const user = await db.users.get(userId); if (!user) { // Phase 3: Outbound (respond) return Response.json({ error: "User not found" }, { status: 404 }); } // Phase 3: Outbound (respond) return Response.json(user); ``` Each phase is explicit. You see exactly when you're receiving, deciding, and responding. ## Facts before decisions The most important principle in Hectoday HTTP: **separate observation from action**. ### What is a fact? A fact is something you've **observed** but not **acted upon**: ```typescript // Facts const method = request.method; // Observed: method is "POST" const authHeader = request.headers.get("authorization"); // Observed: header is missing const body = await request.json(); // Observed: body is { name: 123 } ``` These are observations. Nothing has happened yet. The request hasn't ended. ### What is a decision? A decision is when you **commit to an outcome**: ```typescript // Decision if (!authHeader) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } // If we're here, the request continues // Decision if (typeof body.name !== "string") { return Response.json({ error: "Invalid name" }, { status: 400 }); } // If we're here, the request continues // Decision (success case) return Response.json({ id: 1, name: body.name }, { status: 201 }); // Request ends here ``` Each `return` is a decision boundary. The request ends. ### Why separate them? Most frameworks mix facts and decisions: ```typescript // Does this observe or decide? Both? authenticate(request); // Might return 401, might throw, might continue // Does this observe or decide? Both? validate(body, schema); // Might return 400, might throw, might continue ``` When observation and action are mixed, you can't reason about control flow. You don't know where requests end. ### The Hectoday HTTP way ```typescript // Phase 1: Gather ALL facts const raw = { params: c.raw.params, query: c.raw.query, body: c.raw.body, }; const validation = validator.validate(schema, raw.body); const authToken = c.request.headers.get("authorization"); // Phase 2: Make ALL decisions if (!authToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } if (!validation.ok) { return Response.json({ error: validation.issues }, { status: 400 }); } // Phase 3: Process with certainty // If you're here, auth passed and validation passed const user = await createUser(validation.data); return Response.json(user, { status: 201 }); ``` **Facts are computed once. Decisions are made explicitly. Processing happens only after decisions pass.** ### Why this matters for code structure When you separate facts from decisions, your code has a shape: ```typescript function handler(c) { // Top of function: facts const fact1 = ...; const fact2 = ...; const fact3 = ...; // Middle: decisions (guards) if (factX means "not allowed") { return Response (deny); } if (factY means "invalid") { return Response (error); } // Bottom: processing (if all decisions passed) const result = process(facts); return Response.json(result); } ``` You can read top-to-bottom and see: 1. What facts exist 2. What might cause denial 3. What happens if everything passes No hidden branching. No mysterious returns. Just facts, decisions, code. ### The mental model complete ``` Request arrives (inbound) ↓ Extract facts (observe) ↓ Make decisions (guards) ↓ Process (if allowed) ↓ Return response (outbound) ``` Every request follows this path. Every handler has this shape. When you think this way, HTTP becomes predictable. You know where control flow happens. You know where requests end. You know what code runs when. **This is the mental model Hectoday HTTP is built on.** --- Next: [The web standard foundation](./the-web-standard-foundation) - how Web APIs provide the primitives for this model. # PART 2: CORE CONCEPTS ================================================================================ ## The web standard foundation > How Web Standards provide the primitives for HTTP servers > Source: /docs/the-web-standard-foundation ================================================================================ The mental model from Chapter 1 needs concrete primitives. Fortunately, they already exist: the **Fetch API**. Originally designed for browsers, the Fetch API has become the universal standard for HTTP on both client and server. ## The fetch API (on the server) The Fetch API defines three core primitives: `Request`, `Response`, and `Headers`. Understanding these is understanding HTTP itself. ### Request Every HTTP request is represented by the `Request` object: ```typescript const request = new Request("https://example.com/users/123", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer token123" }, body: JSON.stringify({ name: "Alice" }) }); ``` **On the server, you receive these**, you don't create them: ```typescript // Deno.serve, Bun.serve, Workers - all pass you a Request Deno.serve((request: Request) => { // request.method → "GET", "POST", etc. // request.url → "https://example.com/path?query=value" // request.headers → Headers object // request.body → ReadableStream | null }); ``` #### The properties **`request.method`** - HTTP method as a string ```typescript request.method // "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | ... ``` No magic enums. Just strings. Handle any method: ```typescript if (request.method === "GET") { // ... } else if (request.method === "POST") { // ... } else if (request.method === "PROPFIND") { // WebDAV works too } ``` **`request.url`** - Full URL as a string ```typescript request.url // "https://example.com/users/123?include=posts" ``` Parse it to extract parts: ```typescript const url = new URL(request.url); url.pathname // "/users/123" url.searchParams.get("include") // "posts" url.hostname // "example.com" ``` **`request.headers`** - Headers object ```typescript request.headers.get("content-type") // "application/json" | null request.headers.get("authorization") // "Bearer token" | null ``` Headers are case-insensitive: ```typescript request.headers.get("Content-Type") // same as "content-type" request.headers.get("CONTENT-TYPE") // same as "content-type" ``` **`request.body`** - ReadableStream or null ```typescript // For JSON const data = await request.json(); // For text const text = await request.text(); // For form data const form = await request.formData(); // For binary const buffer = await request.arrayBuffer(); ``` **Important**: The body can only be read once. After `request.json()`, calling `request.text()` will throw. ### Response Every HTTP response is represented by the `Response` object: ```typescript const response = new Response( JSON.stringify({ id: 1, name: "Alice" }), { status: 200, statusText: "OK", headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" } } ); ``` **On the server, you create these** to send back to clients: ```typescript Deno.serve((request: Request) => { // You return a Response return new Response("Hello World"); }); ``` #### Common response patterns **Plain text**: ```typescript return new Response("Hello World"); ``` **JSON** (using the helper): ```typescript return Response.json({ message: "Hello World" }); // Equivalent to: return new Response( JSON.stringify({ message: "Hello World" }), { headers: { "Content-Type": "application/json" } } ); ``` **With status**: ```typescript return Response.json({ error: "Not Found" }, { status: 404 }); ``` **With headers**: ```typescript return new Response("Hello", { headers: { "Content-Type": "text/plain", "X-Custom-Header": "value" } }); ``` **Streaming**: ```typescript const stream = new ReadableStream({ start(controller) { controller.enqueue("chunk 1\n"); controller.enqueue("chunk 2\n"); controller.close(); } }); return new Response(stream); ``` **Empty (no body)**: ```typescript return new Response(null, { status: 204 }); // 204 No Content ``` ### Headers, body, method, URL - the complete picture ```typescript Deno.serve(async (request: Request) => { // Method: what action? console.log(request.method); // "POST" // URL: what resource? const url = new URL(request.url); console.log(url.pathname); // "/users" console.log(url.searchParams.get("filter")); // "active" // Headers: metadata about the request console.log(request.headers.get("content-type")); // "application/json" console.log(request.headers.get("authorization")); // "Bearer token" // Body: the payload const data = await request.json(); // { name: "Alice" } // Return a Response with status, headers, body return Response.json( { id: 1, name: data.name }, { status: 201, headers: { "X-Request-Id": "abc123" } } ); }); ``` This is **standard JavaScript**. No framework concepts. Just Web APIs. ## Why web standards matter Using Web standards instead of framework-specific APIs has profound implications. ### Portability across Deno, Bun, Workers The same code runs everywhere: ```typescript import { route, setup } from "@hectoday/http"; const app = setup({ handlers: [ route.get("/hello", { resolve: () => new Response("Hello World") }) ] }); // Deno Deno.serve(app.fetch); // Bun Bun.serve({ fetch: app.fetch }); // Cloudflare Workers export default { fetch: app.fetch }; // Node.js (with fetch support) import { serve } from "@hono/node-server"; serve({ fetch: app.fetch }); ``` **The handler code doesn't change.** Only the server setup changes. Compare this to framework-specific APIs: ```typescript // Framework A app.get("/hello", (req, res) => { res.send("Hello World"); }); // Framework B app.get("/hello", (ctx) => { return ctx.text("Hello World"); }); // Framework C app.get("/hello", (request, h) => { return h.response("Hello World"); }); ``` Each framework invents its own abstraction. Switching runtimes means rewriting code. ### No framework-shaped abstractions Frameworks often wrap Web standards with their own APIs: ```typescript // Framework wrapper ctx.json({ data: "value" }); ctx.status(201); ctx.header("X-Custom", "value"); // vs Web standard return Response.json( { data: "value" }, { status: 201, headers: { "X-Custom": "value" } } ); ``` The framework version seems convenient, but: 1. **It's one more thing to learn** - `ctx.json()` instead of `Response.json()` 2. **It's one more thing that can break** - framework updates can change the API 3. **It's not portable** - switching frameworks means relearning 4. **It's not documented universally** - you need framework-specific docs Web standards are: - **Already learned** - if you know Fetch API, you know this - **Stable** - standards don't break on minor updates - **Portable** - works everywhere the Web platform exists - **Universally documented** - MDN, specs, endless tutorials ### Future-proof When new runtimes emerge, they implement Web standards first: ```typescript // This code will work on runtimes that don't exist yet // Because they'll implement Request/Response return new Response("Hello World"); ``` Framework-specific code has no such guarantee. ## What hectoday HTTP adds (and what it refuses to) Hectoday HTTP is not "just" the Fetch API. It adds structure without adding abstraction. ### What it adds **Route matching**: ```typescript route.get("/users/:id", { resolve: (c) => { const id = c.raw.params.id; // Extracted from path return Response.json({ id }); } }) ``` You could parse URLs yourself with URLPattern, but Hectoday HTTP does this for you. **Validation results**: ```typescript route.post("/users", { request: { body: schema }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // c.input.body is validated and typed } }) ``` You could call validators yourself, but Hectoday HTTP structures the result consistently. **Guard composition**: ```typescript route.get("/admin", { guards: [requireAuth, requireAdmin], resolve: (c) => { // Only runs if both guards allow return Response.json({ data: "secret" }); } }) ``` You could check auth manually, but Hectoday HTTP gives you a pattern. **Context accumulation**: ```typescript // onRequest adds locals // Guards add more locals // Handler sees all locals resolve: (c) => { const userId = c.locals.userId; // From auth guard const requestId = c.locals.requestId; // From onRequest return Response.json({ userId, requestId }); } ``` You could pass data around manually, but Hectoday HTTP threads it through automatically. ### What it refuses to add **No implicit routing**: ```typescript // Hectoday HTTP: NEVER // Routes don't auto-respond based on file structure // Routes don't auto-generate from decorators // Routes are explicit data structures route.get("/users/:id", config); // You write this ``` **No magic errors**: ```typescript // Hectoday HTTP: NEVER // throw AuthError() → auto 401 // throw ValidationError() → auto 400 // throw NotFoundError() → auto 404 // Instead: explicit returns if (!authorized) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } ``` **No implicit middleware chains**: ```typescript // Hectoday HTTP: NEVER // app.use(middleware1); // app.use(middleware2); // // What order? What runs when? What can short-circuit? // Instead: explicit hooks with clear jobs setup({ onRequest: ({ request }) => ({ requestId: crypto.randomUUID() }), // Before routing handlers: [...], onResponse: ({ context, response }) => addHeaders(response), // After handler onError: ({ error, context }) => handleError(error) // On throw }); ``` Middleware chains have implicit ordering and unclear control flow. Hectoday HTTP makes every step explicit. ### The line Hectoday HTTP adds structure where manual work is repetitive: - Parsing URL patterns - Running validators - Composing guards - Threading context Hectoday HTTP **refuses** to add magic where explicitness matters: - Deciding what responses mean - Controlling when requests end - Hiding where decisions happen **The Web standards provide the primitives. Hectoday HTTP provides the structure. You provide the decisions.** --- Next: [Your first server](./your-first-server) - building a complete handler with what we've learned. ================================================================================ ## Your first server > Building your first Hectoday HTTP server > Source: /docs/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: ```typescript 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 ```typescript 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 ```typescript route.get("/", { resolve: () => new Response("Hello World") }) ``` `route.get()` creates a route descriptor: - **Path**: `"/"` matches requests to the root - **Method**: `.get` matches only GET requests - **Handler**: `resolve` is the function that returns the Response ### The server ```typescript Deno.serve(app.fetch); ``` Pass the `fetch` function to your runtime's server. It works with: - `Deno.serve(app.fetch)` - `Bun.serve({ fetch: app.fetch })` - Cloudflare Workers: `export default { fetch: app.fetch }` **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 ```typescript route.get("/users/:id", { resolve: (c) => { // c is the context // What's inside? } }) ``` The context object (`c`) contains: ```typescript 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; // Request-scoped data } ``` ### `c.request` - The original request ```typescript 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: ```typescript route.get("/users/:id", { resolve: (c) => { const id = c.raw.params.id; // Path parameter return Response.json({ id }); } }) ``` ```typescript 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 }); } }) ``` ```typescript 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: ```typescript 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`: ```typescript // 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 ```typescript route.get("/hello", { resolve: () => new Response("Hello World") }) ``` ### JSON ```typescript route.get("/user", { resolve: () => Response.json({ id: 1, name: "Alice" }) }) ``` ### With status Code ```typescript route.post("/users", { resolve: () => Response.json( { id: 1, name: "Alice" }, { status: 201 } // Created ) }) ``` ### With headers ```typescript route.get("/data", { resolve: () => new Response( JSON.stringify({ data: "value" }), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "max-age=3600" } } ) }) ``` ### Error responses ```typescript route.get("/admin", { resolve: (c) => { if (!c.locals.isAdmin) { return Response.json( { error: "Forbidden" }, { status: 403 } ); } return Response.json({ secret: "data" }); } }) ``` ### Empty response ```typescript 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: ```typescript 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: ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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**: ```typescript 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: ```typescript 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: - "Am I here? Then auth passed." - "Am I here? Then validation passed." - "Am I here? Then creation succeeded." **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](./describing-facts) - how to safely extract and work with request data. ================================================================================ ## Describing Facts > Extracting request data without making decisions > Source: /docs/describing-facts ================================================================================ The request has arrived. The route matched. Now you need to read its data. But **reading is not deciding**. This chapter is about observation: extracting facts from requests without committing to outcomes. ## What is a "fact"? A fact is something you've **observed** but haven't **acted upon**. ### The difference ```typescript // Observing (fact) const authHeader = c.request.headers.get("authorization"); // We know: header exists or doesn't exist // We haven't decided: what that means // Deciding (action) if (!authHeader) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } // We decided: missing header means 401 ``` Facts are passive. Decisions are active. **Separate them.** ### Reading the request without deciding When you read request data, you're gathering information: ```typescript route.get("/users/:id", { resolve: (c) => { // Gathering facts const id = c.raw.params.id; // What ID was requested? const includeParam = c.raw.query.include; // What should be included? const acceptHeader = c.request.headers.get("accept"); // What format? // These are observations. Nothing has happened yet. // The request hasn't ended. No response has been sent. // Now you can decide what these facts mean... } }) ``` You're not validating. You're not authorizing. You're not processing. You're just **reading what exists**. ### Extracting data safely HTTP requests are hostile. Clients can send anything: ```typescript // These are all possible c.raw.params.id // undefined | "123" | "999999999999" | "../../../etc/passwd" | "💩" c.raw.query.page // undefined | "1" | "-1" | "not-a-number" | ["1", "2", "3"] c.raw.body // undefined | { valid: "json" } | "not json" | null | [] ``` **Never trust raw data.** Observe it, then validate it (next chapter). Hectoday HTTP extracts data into `c.raw` without interpreting it: ```typescript interface RawValues { params: Record; query: Record; body?: unknown; } ``` Everything in `c.raw` is **unvalidated**. Use it carefully. ## Reading the URL URLs contain two types of data: **path parameters** and **query parameters**. ### Path parameters Path parameters come from the URL pattern: ```typescript route.get("/users/:id", { resolve: (c) => { const id = c.raw.params.id; // id is string | undefined return Response.json({ id }); } }) // GET /users/123 → id = "123" // GET /users/abc → id = "abc" // GET /users/ → route doesn't match ``` Multiple parameters: ```typescript route.get("/orgs/:orgId/repos/:repoId", { resolve: (c) => { const orgId = c.raw.params.orgId; // string | undefined const repoId = c.raw.params.repoId; // string | undefined return Response.json({ orgId, repoId }); } }) // GET /orgs/acme/repos/http // → orgId = "acme", repoId = "http" ``` **Important**: Path params are always strings (if they exist). Even if the URL is `/users/123`, `c.raw.params.id` is the **string** `"123"`, not the number `123`. To use as a number: ```typescript route.get("/users/:id", { resolve: (c) => { const idStr = c.raw.params.id; // Validate it's a number if (!idStr || !/^\d+$/.test(idStr)) { return Response.json({ error: "Invalid ID" }, { status: 400 }); } // Now safe to parse const id = parseInt(idStr, 10); return Response.json({ id }); } }) ``` Or use validation (next chapter): ```typescript route.get("/users/:id", { request: { params: z.object({ id: z.string().regex(/^\d+$/).transform(Number) }) }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const id = c.input.params.id; // number (typed and validated) return Response.json({ id }); } }) ``` ### Query parameters Query parameters come from the URL search string: ```typescript route.get("/search", { resolve: (c) => { const query = c.raw.query.q; const page = c.raw.query.page; return Response.json({ query, page }); } }) // GET /search?q=hello&page=2 // → query = "hello", page = "2" ``` **Query params are also strings**: ```typescript // GET /search?page=2 c.raw.query.page // "2" (string), not 2 (number) ``` ### Query parameter arrays Query parameters can appear multiple times: ```typescript route.get("/filter", { resolve: (c) => { const tags = c.raw.query.tag; // tags is string | string[] | undefined return Response.json({ tags }); } }) // GET /filter?tag=javascript // → tags = "javascript" // GET /filter?tag=javascript&tag=typescript // → tags = ["javascript", "typescript"] ``` Handle both cases: ```typescript route.get("/filter", { resolve: (c) => { const tagsRaw = c.raw.query.tag; // Normalize to array const tags = tagsRaw === undefined ? [] : Array.isArray(tagsRaw) ? tagsRaw : [tagsRaw]; return Response.json({ tags }); } }) ``` ### Missing query parameters ```typescript route.get("/search", { resolve: (c) => { const query = c.raw.query.q; if (!query) { return Response.json( { error: "Missing query parameter 'q'" }, { status: 400 } ); } return Response.json({ query }); } }) // GET /search // → query = undefined → returns 400 ``` ### Search params vs internal naming The URL uses `?key=value` syntax. Internally, you access via object properties: ```typescript // URL: /search?q=hello&max_results=10&include-draft=true route.get("/search", { resolve: (c) => { const q = c.raw.query.q; // "hello" const maxResults = c.raw.query.max_results; // "10" const includeDraft = c.raw.query["include-draft"]; // "true" // Note: hyphens and underscores are preserved return Response.json({ q, maxResults, includeDraft }); } }) ``` JavaScript property access rules apply: - `c.raw.query.max_results` works (valid identifier) - `c.raw.query["include-draft"]` needed (hyphen not valid in dot notation) ### The full URL If you need more than params and query, use `c.request.url`: ```typescript route.get("/example", { resolve: (c) => { const url = new URL(c.request.url); url.protocol // "https:" url.hostname // "example.com" url.port // "443" url.pathname // "/example" url.search // "?key=value" url.hash // "#section" (rarely used on server) return Response.json({ url: url.href }); } }) ``` ## Reading the body Request bodies are more complex than URLs. They're optional, can be large, and come in various formats. ### When a body exists Not all requests have bodies: ```typescript // These typically have NO body GET /users HEAD /users DELETE /users/123 // These typically HAVE a body POST /users PUT /users/123 PATCH /users/123 ``` But HTTP doesn't enforce this. A GET request **can** have a body (though it's rare and discouraged). Check if a body exists: ```typescript route.post("/users", { resolve: async (c) => { if (!c.request.body) { return Response.json( { error: "Missing request body" }, { status: 400 } ); } // Body exists, read it const data = await c.request.json(); return Response.json(data); } }) ``` ### Reading the body manually The Fetch API provides several methods: ```typescript // JSON const data = await c.request.json(); // Text const text = await c.request.text(); // Form data const form = await c.request.formData(); // Binary const buffer = await c.request.arrayBuffer(); ``` **Warning**: You can only read the body **once**: ```typescript route.post("/echo", { resolve: async (c) => { const text1 = await c.request.text(); const text2 = await c.request.text(); // ❌ Throws! Body already consumed return new Response(text1); } }) ``` ### JSON as a fact, not a guarantee When you call `c.request.json()`, you're asking: "Parse this body as JSON." But the body might not be JSON: ```typescript route.post("/users", { resolve: async (c) => { try { const data = await c.request.json(); // Data is unknown - could be anything return Response.json({ received: data }); } catch (error) { // Body wasn't valid JSON return Response.json( { error: "Invalid JSON" }, { status: 400 } ); } } }) ``` The client might send: - Valid JSON: `{ "name": "Alice" }` - Invalid JSON: `{ name: Alice }` (missing quotes) - Not JSON at all: `data` - Empty body: (nothing) **Never assume the body is what you expect.** Validate it. ### Hectoday HTTP's body parsing When you define a `body` schema, Hectoday HTTP automatically parses the body **as JSON**: ```typescript route.post("/users", { request: { body: z.object({ name: z.string() }) }, resolve: (c) => { // Hectoday HTTP already parsed c.request.json() // Result is in c.raw.body if (!c.input.ok) { // Either JSON parsing failed OR validation failed return Response.json({ error: c.input.issues }, { status: 400 }); } // Body is valid JSON AND passed schema validation const name = c.input.body.name; return Response.json({ name }); } }) ``` **How it works**: 1. Framework sees you defined `request.body` schema 2. Framework calls `c.request.json()` automatically 3. If JSON parsing fails → `c.input.ok = false`, issue: "Invalid JSON" 4. If JSON parsing succeeds → validate against schema 5. If validation fails → `c.input.ok = false`, issues from validator 6. If validation passes → `c.input.ok = true`, typed data in `c.input.body` **Important limitations**: - Only JSON is auto-parsed (not form data, not XML, not multipart) - Body is only parsed when you define a `body` schema - If you need non-JSON bodies, parse manually: ```typescript route.post("/upload", { resolve: async (c) => { const formData = await c.request.formData(); const file = formData.get("file"); if (!file || typeof file === "string") { return Response.json({ error: "No file provided" }, { status: 400 }); } // file is a File object const bytes = await file.arrayBuffer(); return Response.json({ size: bytes.byteLength }); } }) ``` ### Empty bodies An empty body is not an error, it's a fact: ```typescript route.post("/ping", { resolve: async (c) => { const body = c.raw.body; // undefined (if no schema defined) // Or with schema: // const text = await c.request.text(); // "" (empty string) return Response.json({ received: body === undefined }); } }) ``` If your endpoint requires a body, **you** must check: ```typescript route.post("/users", { resolve: async (c) => { if (!c.request.body) { return Response.json( { error: "Request body required" }, { status: 400 } ); } const data = await c.request.json(); return Response.json(data); } }) ``` Or define a schema (which will fail validation if body is empty): ```typescript route.post("/users", { request: { body: z.object({ name: z.string() }) // Empty body will fail }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } return Response.json(c.input.body); } }) ``` ### Content-Type header The `Content-Type` header tells you what format the body is: ```typescript route.post("/data", { resolve: async (c) => { const contentType = c.request.headers.get("content-type"); if (contentType === "application/json") { const data = await c.request.json(); return Response.json(data); } else if (contentType === "text/plain") { const text = await c.request.text(); return new Response(text); } else { return Response.json( { error: `Unsupported content type: ${contentType}` }, { status: 415 } // Unsupported Media Type ); } } }) ``` **But don't trust it**. Clients can lie: ```typescript // Client sends: // Content-Type: application/json // Body: not json const data = await c.request.json(); // Throws! ``` Always wrap parsing in try/catch or use validation. ### Facts about the body To summarize: **Facts**: - Body exists or doesn't exist: `c.request.body !== null` - Content-Type header says something: `c.request.headers.get("content-type")` - Body can be read as text/JSON/form/binary: `await c.request.json()` **Not facts**: - Body is valid JSON (must try parsing) - Body matches your schema (must validate) - Body is the right format (must check and handle errors) **Observation**: "The body claims to be JSON and when parsed contains `{ name: 123 }`" **Decision**: "A name must be a string, so this request is invalid → return 400" --- Next: [Validation without control flow](./validation-without-control-flow) - turning raw facts into validated data. ================================================================================ ## Validation without control flow > Using validation to describe data, not control requests > Source: /docs/validation-without-control-flow ================================================================================ You've extracted facts from the request. Now you need to know: **does this data match what you expect?** Validation answers that question. But in Hectoday HTTP, validation **never decides what happens next**. It only describes whether data is valid. ## Validation as description Most frameworks make validation a decision boundary: ```typescript // In many frameworks, this line might return 400 validate(body, schema); // Are we still here? Did it throw? Did it return? Who knows? ``` Hectoday HTTP treats validation as **observation**: ```typescript // This never returns, never throws (to you) const result = validator.validate(schema, body, "body"); // You're definitely still here // Now you decide what the result means if (!result.ok) { return Response.json({ error: result.issues }, { status: 400 }); } ``` ### The distinction **Validation** answers: "Does this data match the schema?" **Decision** answers: "What should I do about it?" These are separate concerns: ```typescript route.post("/users", { request: { body: z.object({ name: z.string(), age: z.number().min(0) }) }, resolve: (c) => { // Validation describes the data if (!c.input.ok) { // ↑ FACT: data doesn't match schema // ↓ DECISION: what does that mean? return Response.json( { error: "Invalid user data", issues: c.input.issues }, { status: 400 } ); } // If we're here, validation passed const { name, age } = c.input.body; return Response.json({ name, age }); } }) ``` Validation computed a fact: `c.input.ok = false`. You decided what it means: return 400. ### Why this matters When validation and control flow are separate, you can: **Use validation results multiple ways**: ```typescript if (!c.input.ok) { // Different routes, different responses if (c.request.headers.get("accept") === "application/json") { return Response.json({ errors: c.input.issues }, { status: 400 }); } else { return new Response( `Validation failed: ${c.input.issues.map(i => i.message).join(", ")}`, { status: 400, headers: { "Content-Type": "text/plain" } } ); } } ``` **Log before responding**: ```typescript if (!c.input.ok) { console.warn("Validation failed:", { path: c.request.url, issues: c.input.issues, received: c.input.received }); return Response.json({ error: c.input.issues }, { status: 400 }); } ``` **Transform validation errors**: ```typescript if (!c.input.ok) { // Custom error format const errors = c.input.issues.reduce((acc, issue) => { const key = issue.path.join("."); acc[key] = issue.message; return acc; }, {} as Record); return Response.json({ errors }, { status: 400 }); } ``` If validation auto-returned, you couldn't do any of this. ## Valid vs invalid ≠ allowed vs denied This is crucial: **validation and authorization are different concerns**. ### Valid data can be denied ```typescript route.post("/admin/users", { guards: [requireAdmin], request: { body: z.object({ name: z.string() }) }, resolve: (c) => { // Data is valid (or we'd see c.input.ok = false) if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // Data is valid, but... if (c.input.body.name === "root") { // Business rule: can't create user named "root" return Response.json( { error: "Reserved username" }, { status: 422 } // Unprocessable Entity ); } return Response.json({ created: c.input.body.name }); } }) ``` The data **is** valid JSON matching the schema. But it's still rejected because of a business rule. ### Invalid data might not mean 400 ```typescript route.post("/users", { request: { body: z.object({ name: z.string() }) }, resolve: (c) => { if (!c.input.ok) { // Maybe log and return 500 for unexpected formats if (c.input.issues.some(i => i.message === "Invalid JSON")) { console.error("Client sent malformed JSON"); return Response.json( { error: "Request format error" }, { status: 500 } // Not their fault, maybe ); } // Normal validation errors return Response.json({ error: c.input.issues }, { status: 400 }); } return Response.json({ name: c.input.body.name }); } }) ``` You control the semantics. Validation just describes the data. ### The layers ``` Request arrives ↓ Extract raw data (facts) ↓ Validate (facts about validity) ↓ Authorize (decision: allowed?) ↓ Business rules (decision: acceptable?) ↓ Process (action) ``` Each layer has a different job. **Validation is just one fact-gathering step.** ## Validators return information When Hectoday HTTP runs validation, it calls your validator adapter: ```typescript interface Validator { validate( schema: S, input: unknown, part: "params" | "query" | "body" ): ValidateResult, InferSchemaError>; } type ValidateResult = | { ok: true; value: T } | { ok: false; issues: ValidationIssue[]; error?: TErr }; ``` Validators **return data**, they don't control flow. ### Success When data is valid: ```typescript { ok: true, value: { name: "Alice", age: 30 } } ``` The `value` is the validated, typed data. Use it safely: ```typescript if (c.input.ok) { const name = c.input.body.name; // Type-safe: string const age = c.input.body.age; // Type-safe: number } ``` ### Failure When data is invalid: ```typescript { ok: false, issues: [ { part: "body", path: ["age"], message: "Expected number, received string", code: "invalid_type" } ], error: /* original error from validator library */ } ``` The `issues` array contains normalized errors: ```typescript interface ValidationIssue { part: "params" | "query" | "body"; path: readonly string[]; message: string; code?: string; } ``` ### Metadata The validator can include the original error object: ```typescript { ok: false, issues: [/* normalized */], error: zodError // The original Zod error object } ``` This lets you access library-specific details: ```typescript if (!c.input.ok && c.input.errors?.body) { // c.input.errors.body is the original validator error const zodError = c.input.errors.body as z.ZodError; // Use Zod-specific features const formatted = zodError.format(); return Response.json({ errors: formatted }, { status: 400 }); } ``` But the normalized `issues` array is always available for generic handling. ### What gets validated Hectoday HTTP validates three parts independently: ```typescript 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) => { if (!c.input.ok) { // c.input.failed tells you which parts failed console.log("Failed parts:", c.input.failed); // ["params"] or ["query", "body"] // c.input.issues contains all issues across all parts return Response.json({ error: c.input.issues }, { status: 400 }); } // All parts validated successfully const orgId = c.input.params.orgId; // string (UUID) const notify = c.input.query.notify; // boolean const { name, email } = c.input.body; // { name: string, email: string } return Response.json({ orgId, notify, name, email }); } }) ``` Each schema is optional. Validate only what you need: ```typescript // Just params route.get("/users/:id", { request: { params: z.object({ id: z.string().uuid() }) }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const id = c.input.params.id; return Response.json({ id }); } }) // Just body route.post("/users", { request: { body: z.object({ name: z.string() }) }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const name = c.input.body.name; return Response.json({ name }); } }) // No validation route.get("/health", { resolve: () => Response.json({ status: "ok" }) // c.input.ok is always true when no schemas defined }) ``` ## Composing validators Validation schemas compose **at the data level**, not the control flow level. ### Layering without branching Build complex schemas from simple ones: ```typescript // Reusable schemas const NameSchema = z.string().min(1).max(100); const EmailSchema = z.string().email(); const AgeSchema = z.number().int().min(0).max(150); // Compose them const UserSchema = z.object({ name: NameSchema, email: EmailSchema, age: AgeSchema }); const AdminUserSchema = UserSchema.extend({ permissions: z.array(z.string()) }); // Use in routes route.post("/users", { request: { body: UserSchema }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } return Response.json(c.input.body); } }) route.post("/admin/users", { request: { body: AdminUserSchema }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } return Response.json(c.input.body); } }) ``` **No branching**. Each route independently declares its schema. Validation runs once. You decide what to do with the result. ### Conditional validation Sometimes validation depends on context: ```typescript route.patch("/users/:id", { resolve: async (c) => { const id = c.raw.params.id; const updates = c.raw.body as Record; // Different schemas for different update types if (updates.type === "email") { const schema = z.object({ type: z.literal("email"), email: z.string().email() }); const result = schema.safeParse(updates); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); } await updateEmail(id, result.data.email); return Response.json({ updated: "email" }); } if (updates.type === "password") { const schema = z.object({ type: z.literal("password"), password: z.string().min(8) }); const result = schema.safeParse(updates); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); } await updatePassword(id, result.data.password); return Response.json({ updated: "password" }); } return Response.json({ error: "Unknown update type" }, { status: 400 }); } }) ``` You can validate manually when needed. Hectoday HTTP doesn't force you into one pattern. ### Validator adapters Hectoday HTTP is validator-agnostic. Bring your own: **For a complete, copy-paste ready Zod validator adapter**, see the [Zod Validator helper documentation](./helpers/zod-validator). **Zod** (simplified example): ```typescript import { z } from "zod"; const validator = { validate(schema: z.ZodType, input: unknown, part: string) { const result = schema.safeParse(input); if (result.success) { return { ok: true, value: result.data }; } return { ok: false, issues: result.error.issues.map(issue => ({ part, path: issue.path.map(String), message: issue.message, code: issue.code })), error: result.error }; } }; ``` **Valibot**: ```typescript import * as v from "valibot"; const validator = { validate(schema: v.BaseSchema, input: unknown, part: string) { const result = v.safeParse(schema, input); if (result.success) { return { ok: true, value: result.output }; } return { ok: false, issues: result.issues.map(issue => ({ part, path: issue.path?.map(p => String(p.key)) ?? [], message: issue.message, code: issue.type })), error: result.issues }; } }; ``` **ArkType**: ```typescript import type { Type } from "arktype"; const validator = { validate(schema: Type, input: unknown, part: string) { const result = schema(input); if (result instanceof type.errors) { return { ok: false, issues: result.map(err => ({ part, path: err.path, message: err.message, code: err.code })), error: result }; } return { ok: true, value: result }; } }; ``` All adapt the same interface. Your handlers don't change when you switch validators. ### No validator required If you don't define schemas, you don't need a validator: ```typescript const app = setup({ handlers: [ route.get("/health", { resolve: () => Response.json({ status: "ok" }) }) ] // No validator needed }); ``` Validation is opt-in. Use it when you need type safety and structured error handling. Skip it when you don't. ### The pattern ``` Request arrives ↓ Extract raw data (unsafe) ↓ Validate (produce fact: valid or invalid) ↓ Check c.input.ok (you decide what it means) ↓ If invalid → return error response (you choose format and status) ↓ If valid → use c.input.* (type-safe, validated data) ``` Validation produces facts. You make decisions. **No hidden control flow.** --- Next: [Guards: making decisions explicit](./guards-making-decisions-explicit) - the other half of request control. ================================================================================ ## Guards: making decisions explicit > Using guards to control request flow explicitly > Source: /docs/guards-making-decisions-explicit ================================================================================ You've extracted facts. You've validated data. Now comes the critical question: **should this request be allowed to continue?** Guards answer this question. They are the **only** place besides handlers where requests can end. ## What is a guard? A guard is a function that makes an allow/deny decision: ```typescript type GuardFn = (c: Context) => GuardResult | Promise; type GuardResult = | { allow: true; locals?: object } | { deny: Response }; ``` That's it. A guard receives context, returns a decision. ### A decision boundary Guards are **decision boundaries**, places where the request might end: ```typescript const requireAuth: GuardFn = (c) => { const token = c.request.headers.get("authorization"); if (!token) { // REQUEST ENDS HERE return { deny: Response.json({ error: "Unauthorized" }, { status: 401 }) }; } const user = verifyToken(token); if (!user) { // REQUEST ENDS HERE return { deny: Response.json({ error: "Invalid token" }, { status: 401 }) }; } // Request continues, user data attached return { allow: true, locals: { user } }; }; ``` If a guard returns `{ deny: Response }`, the request ends immediately. The handler never runs. The response is sent. If a guard returns `{ allow: true }`, the request continues to the next guard (if any) or to the handler. ### The moment intent becomes outcome Before the guard runs, the request is in limbo, it **might** be allowed: ```typescript route.get("/admin", { guards: [requireAuth, requireAdmin], resolve: (c) => { // If we're here, both guards allowed return Response.json({ data: "secret" }); } }) ``` The guards convert **intent** (headers, tokens, data) into **outcome** (allowed or denied). ``` Intent (headers, body, etc.) ↓ Guards evaluate ↓ Outcome (allow or deny) ``` This is different from validation: - **Validation** asks: "Is this data well-formed?" - **Guards** ask: "Should this request proceed?" ### Using guards Attach guards to routes: ```typescript route.get("/profile", { guards: [requireAuth], resolve: (c) => { // Only runs if requireAuth allowed const user = c.locals.user; return Response.json({ user }); } }) ``` Multiple guards run in order: ```typescript route.delete("/users/:id", { guards: [requireAuth, requireAdmin, requireNotSelf], resolve: (c) => { // Only runs if all three guards allowed const id = c.raw.params.id; // Delete user... return new Response(null, { status: 204 }); } }) ``` First denial wins. If any guard denies, the request ends. ## Allow vs Deny Guards have exactly two outcomes. No exceptions, no implicit behavior. ### Allow When a guard allows, it returns `{ allow: true }`: ```typescript 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 }; }; ``` **The request continues.** The next guard runs, or if this was the last guard, the handler runs. ### Allow with Locals Guards can attach data to the request: ```typescript 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 }) }; } // Attach user to locals return { allow: true, locals: { user } }; }; ``` The `locals` object is merged into `c.locals` for subsequent guards and the handler: ```typescript route.get("/profile", { guards: [requireAuth], resolve: (c) => { // user was attached by requireAuth const user = c.locals.user; return Response.json({ user }); } }) ``` This is how guards pass data forward without mutation: ``` Guard 1: { allow: true, locals: { user } } ↓ Guard 2 sees: c.locals = { user } Guard 2: { allow: true, locals: { role: "admin" } } ↓ Handler sees: c.locals = { user, role: "admin" } ``` Each guard adds to locals. Nothing is mutated. Data only flows forward. ### Deny When a guard denies, it returns `{ deny: Response }`: ```typescript const requireAdmin: GuardFn = (c) => { const user = c.locals.user; if (!user || user.role !== "admin") { // REQUEST ENDS HERE return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) }; } return { allow: true }; }; ``` **The request ends immediately.** No subsequent guards run. The handler never runs. The `Response` is sent to the client. The guard controls the exact response: ```typescript const requireValidApiKey: GuardFn = (c) => { const apiKey = c.request.headers.get("x-api-key"); if (!apiKey) { return { deny: Response.json( { error: "Missing API key", docs: "https://example.com/docs/auth" }, { status: 401 } ) }; } if (!isValidApiKey(apiKey)) { return { deny: Response.json( { error: "Invalid API key" }, { status: 401 } ) }; } return { allow: true, locals: { apiKeyId: apiKey.slice(0, 8) } }; }; ``` You decide the status code, the body, the headers. Guards are explicit. ### Why there is no "throw" Some frameworks let you throw to deny: ```typescript // In other frameworks function requireAuth(c) { const token = c.headers.get("authorization"); if (!token) { throw new UnauthorizedError(); // Implicitly becomes 401 } // Request continues? } ``` **This hides control flow.** You can't tell from reading the code: - What response is sent - What status code is used - Whether the request ends here Hectoday HTTP makes it explicit: ```typescript const requireAuth: GuardFn = (c) => { const token = c.request.headers.get("authorization"); if (!token) { // Explicit: request ends, 401 sent, this exact response return { deny: Response.json({ error: "Unauthorized" }, { status: 401 }) }; } return { allow: true }; }; ``` You see: - Request ends here (return statement) - Status is 401 - Body is `{ error: "Unauthorized" }` **No magic. No hidden mappings. Just explicit returns.** ### Why guards must be explicit Implicit guards create confusion: ```typescript // In other frameworks app.use(authMiddleware); app.use(loggingMiddleware); app.use(rateLimitMiddleware); app.get("/users", handler); // Which middleware runs? // Which can deny the request? // In what order? // What responses do they return? ``` Hectoday HTTP guards are explicit: ```typescript route.get("/users", { guards: [requireAuth, requireRateLimit], resolve: (c) => { // We know: auth checked, rate limit checked // We know: both allowed (or we wouldn't be here) return Response.json({ users: [] }); } }) ``` Reading the route definition tells you: 1. What guards run 2. In what order 3. What must pass for the handler to run **The code is the documentation.** ## Guard ordering Guards run in the order you specify. This order is **part of your API design**. ### Order matters ```typescript route.get("/admin/users", { guards: [requireAuth, requireAdmin], resolve: (c) => { return Response.json({ users: [] }); } }) ``` This means: 1. Check authentication first 2. If auth passes, check admin role 3. If both pass, run handler **Don't do this**: ```typescript route.get("/admin/users", { guards: [requireAdmin, requireAuth], // Wrong order! resolve: (c) => { return Response.json({ users: [] }); } }) ``` If `requireAdmin` checks `c.locals.user` but `requireAuth` hasn't run yet, `user` won't exist. The guard will fail incorrectly. ### Dependencies between guards Guards can depend on previous guards' locals: ```typescript 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 }) }; } // Provides user return { allow: true, locals: { user } }; }; const requireAdmin: GuardFn = (c) => { // Depends on user from requireAuth const user = c.locals.user; if (!user || user.role !== "admin") { return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) }; } return { allow: true }; }; const requireNotSelf: GuardFn = (c) => { // Depends on user from requireAuth const user = c.locals.user; const targetId = c.raw.params.id; if (user.id === targetId) { return { deny: Response.json({ error: "Cannot modify yourself" }, { status: 400 }) }; } return { allow: true }; }; // Correct order: requireAuth must be first route.delete("/users/:id", { guards: [requireAuth, requireAdmin, requireNotSelf], resolve: (c) => { // All checks passed return new Response(null, { status: 204 }); } }) ``` **The order is explicit.** You can trace the data flow by reading the guard array. ### Short-circuiting The first guard to deny ends the request: ```typescript route.get("/admin", { guards: [requireAuth, requireAdmin, requireEmailVerified], resolve: (c) => { return Response.json({ data: "secret" }); } }) // Request with no token // → requireAuth denies with 401 // → requireAdmin never runs // → requireEmailVerified never runs // → handler never runs // Request with valid token, but not admin // → requireAuth allows // → requireAdmin denies with 403 // → requireEmailVerified never runs // → handler never runs // Request with valid admin token, but unverified email // → requireAuth allows // → requireAdmin allows // → requireEmailVerified denies with 403 // → handler never runs // Request with valid admin token and verified email // → requireAuth allows // → requireAdmin allows // → requireEmailVerified allows // → handler runs ``` This is **sequential evaluation**. Not parallel, not all-at-once. One by one, in order. ### Why order is part of your API design The order affects: **Performance**: Check cheap conditions first: ```typescript route.get("/expensive", { guards: [ checkRateLimit, // Fast: just a counter check requireAuth, // Medium: token verification requirePremium, // Medium: database lookup checkResourceExists // Slow: database query ], resolve: (c) => { return Response.json({ data: "..." }); } }) ``` If rate limit is exceeded, you never hit the database. **Security**: Check authentication before authorization: ```typescript route.get("/admin", { guards: [ requireAuth, // First: who are you? requireAdmin // Second: are you allowed? ], resolve: (c) => { return Response.json({ data: "secret" }); } }) ``` Never check permissions before identity. You need to know **who** before checking **what they can do**. **Error messages**: Fail fast with specific errors: ```typescript route.post("/transfer", { guards: [ requireAuth, // 401 if not authenticated requireValidTransfer, // 400 if invalid transfer data requireSufficientFunds // 402 if insufficient funds ], resolve: (c) => { // Execute transfer return Response.json({ success: true }); } }) ``` The client gets the **first** error they encounter. Order determines which error they see. ### Reusable guards Guards are just functions. Share them: ```typescript // guards.ts export const requireAuth: GuardFn = (c) => { // ... }; export const requireAdmin: GuardFn = (c) => { // ... }; export const requireEmailVerified: GuardFn = (c) => { // ... }; // routes.ts import { requireAuth, requireAdmin } from "./guards.ts"; route.get("/admin/users", { guards: [requireAuth, requireAdmin], resolve: (c) => { return Response.json({ users: [] }); } }) route.delete("/admin/users/:id", { guards: [requireAuth, requireAdmin], resolve: (c) => { return new Response(null, { status: 204 }); } }) ``` Same guards, different routes. **Composition without hidden behavior.** ### Parameterized guards Guards can be factories: ```typescript const requireRole = (role: string): GuardFn => { return (c) => { const user = c.locals.user; if (!user || user.role !== role) { return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) }; } return { allow: true }; }; }; // Use it route.get("/admin", { guards: [requireAuth, requireRole("admin")], resolve: (c) => { return Response.json({ data: "secret" }); } }) route.get("/moderator", { guards: [requireAuth, requireRole("moderator")], resolve: (c) => { return Response.json({ data: "mod panel" }); } }) ``` **Still explicit.** The factory creates a guard, you see it in the array. ### Guard composition with `group()` Apply guards to multiple routes at once: ```typescript import { group } from "@hectoday/http"; const adminRoutes = group({ guards: [requireAuth, requireAdmin], handlers: [ route.get("/admin/users", { resolve: (c) => Response.json({ users: [] }) }), route.delete("/admin/users/:id", { resolve: (c) => new Response(null, { status: 204 }) }), route.post("/admin/settings", { resolve: (c) => Response.json({ updated: true }) }) ] }); const app = setup({ handlers: [...adminRoutes] }); ``` All routes in the group get the guards. **Still explicit, the group definition shows the guards.** Routes can add their own guards: ```typescript const adminRoutes = group({ guards: [requireAuth, requireAdmin], handlers: [ route.get("/admin/users", { resolve: (c) => Response.json({ users: [] }) }), route.delete("/admin/users/:id", { guards: [requireNotSelf], // Additional guard resolve: (c) => new Response(null, { status: 204 }) }) ] }); // For the DELETE route, guards run in order: // 1. requireAuth (from group) // 2. requireAdmin (from group) // 3. requireNotSelf (from route) ``` **Layering, not hiding.** You see exactly what guards apply. --- Next: [The request lifecycle (fully explicit)](./the-request-lifecycle-fully-explicit) - putting all the pieces together. # PART 3: COMPOSITION ================================================================================ ## The request lifecycle (fully explicit) > Understanding the complete path of every request > Source: /docs/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: ```typescript 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**: - Parse URL path against route pattern → `c.raw.params` - Parse URL search string → `c.raw.query` - Parse body as JSON (if body schema defined) → `c.raw.body` **What the framework doesn't do**: - Validate the data - Make decisions about it - Return responses based on it ### Validate If you defined schemas, the framework runs validation: ```typescript 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**: - Call your validator adapter with each schema - Collect all issues across all parts - Set `c.input.ok` to true or false - Provide typed data if validation passed **What the framework doesn't do**: - Return 400 automatically - Format error messages - Stop the request - Log validation failures **You decide** what validation failures mean. ### Guard Guards run in order. Each can allow or deny: ```typescript 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**: - Run guards in order - Stop at first denial (send that response) - Accumulate locals from each allow - Call handler if all guards allowed **What the framework doesn't do**: - Decide what "allowed" means (you define guard logic) - Automatically check authentication (you write requireAuth) - Automatically check authorization (you write requireAdmin) - Log denials - Retry failed guards **You decide** what must pass for a request to continue. ### Handle The handler receives context and returns a Response: ```typescript 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**: - Call your handler with context - Pass the Response you return to the client **What the framework doesn't do**: - Catch errors (they go to onError if thrown) - Validate the Response (any Response is valid) - Add default headers - Log the response - Transform the response **You decide** everything about the response. ### Respond The response goes back to the client: ```typescript 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**: - Take the Response you returned - Send it to the client **What the framework doesn't do**: - Add CORS headers (you add them) - Add caching headers (you add them) - Compress the response (runtime handles it) - Log the response (you log it in onResponse) 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: ```typescript 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 ```bash POST /orgs/123e4567-e89b-12d3-a456-426614174000/users Authorization: Bearer valid-token-here Content-Type: application/json {"name":"Alice","email":"alice@example.com"} ``` **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: "alice@example.com" } 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: "alice@example.com" } ``` ### Failed request (invalid input) ```bash 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) ```bash POST /orgs/123e4567-e89b-12d3-a456-426614174000/users Content-Type: application/json {"name":"Alice","email":"alice@example.com"} ``` **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**: ```typescript 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**: ```typescript 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: ```typescript 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: ```typescript 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**: ```typescript 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**: ```typescript // 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**: ```typescript // 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**: ```typescript // 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**: ```typescript // 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](./hooks-the-three-extension-points) - understanding onRequest, onResponse, and onError in depth. ================================================================================ ## Hooks: the three extension points > Understanding onRequest, onResponse, and onError hooks > Source: /docs/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 ```typescript 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 ```typescript 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` (or Promise of either): ```typescript // 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:** ```typescript 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: ```typescript // ❌ 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:** ```typescript onRequest: ({ request }) => { const requestId = request.headers.get("x-request-id") || crypto.randomUUID(); return { requestId }; } ``` **Request logging:** ```typescript onRequest: ({ request }) => { const start = performance.now(); console.log(`→ ${request.method} ${request.url}`); return { start }; } ``` **Session loading:** ```typescript onRequest: async ({ request }) => { const sessionId = request.headers.get("cookie")?.match(/session=([^;]+)/)?.[1]; if (!sessionId) return {}; const session = await loadSession(sessionId); return { session }; } ``` **Environment injection:** ```typescript // 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 ```typescript 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): ```typescript // 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:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript 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 ```typescript 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): ```typescript // 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:** ```typescript route.get("/test", { resolve: () => { throw new Error("Handler error"); // → onError } }) ``` **Guards:** ```typescript route.get("/test", { guards: [(c) => { throw new Error("Guard error"); // → onError }], resolve: () => new Response("OK") }) ``` **Async errors:** ```typescript route.get("/test", { resolve: async () => { await fetch("https://broken.com"); // Throws → onError } }) ``` ### What doesn't throw **Explicit Response returns:** ```typescript route.get("/test", { resolve: () => { // This is NOT an error, onError doesn't run return Response.json({ error: "Not found" }, { status: 404 }); } }) ``` **Guard denials:** ```typescript 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:** ```typescript 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:** ```typescript 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:** ```typescript 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: ```typescript 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: ```typescript 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: ```typescript // 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: ```typescript // 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 added - `onResponse`: Response returned unmodified - `onError`: 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: ```typescript // 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: ```typescript 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](./errors-are-responses) - how to handle errors explicitly without throwing. ================================================================================ ## Errors are responses > Handling failures explicitly without exceptions > Source: /docs/errors-are-responses ================================================================================ Not all requests succeed. Some fail because of invalid input. Some fail because of authorization. Some fail because of bugs. In Hectoday HTTP, **expected failures are responses**. Only unexpected failures are exceptions. ## Errors are not exceptions Most frameworks blur the line between expected and unexpected failures: ```typescript // In many frameworks app.post("/users", (req, res) => { // Is this expected or unexpected? if (!req.body.email) { throw new ValidationError("Email required"); // → 400 } // Is this expected or unexpected? if (!req.user) { throw new UnauthorizedError(); // → 401 } // Is this expected or unexpected? const user = await db.users.create(req.body); // Might throw → 500 }); ``` When you throw, you're saying: "Something went wrong, framework figure it out." The framework maps exceptions to status codes. `ValidationError` becomes 400. `UnauthorizedError` becomes 401. Database errors become 500. **This is implicit.** You can't tell from reading the handler what status codes it returns. ### Hectoday HTTP's model In Hectoday HTTP, there are two types of failures: **Expected failures**: Return a Response explicitly ```typescript route.post("/users", { resolve: async (c) => { // Expected failure: invalid input if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // Expected failure: unauthorized if (!c.locals.user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } // Expected failure: duplicate email const existing = await db.users.findByEmail(c.input.body.email); if (existing) { return Response.json({ error: "Email already exists" }, { status: 409 }); } // Success const user = await db.users.create(c.input.body); return Response.json(user, { status: 201 }); } }) ``` **Unexpected failures**: Throw (goes to onError) ```typescript route.get("/users", { resolve: async (c) => { // If this throws, it's unexpected (bug, infrastructure failure) const users = await db.users.getAll(); return Response.json(users); } }) ``` ### The distinction **Expected failure**: - You know it might happen - It's part of normal operation - The client can handle it - Examples: validation errors, not found, unauthorized, conflict **Unexpected failure**: - You didn't expect it to happen - It's a bug or infrastructure problem - The client can't do much about it - Examples: database connection lost, out of memory, null pointer ### Why throwing breaks the mental model When you throw to indicate expected failures, you hide control flow: ```typescript // Where does this request end? function handler() { validateInput(data); // Might throw → 400 checkAuth(token); // Might throw → 401 checkPermissions(); // Might throw → 403 // Are we here? Or did we already return? const result = process(); return Response.json(result); } ``` You can't tell by reading this code: - What status codes this handler returns - Where the request might end - What error responses look like With explicit returns, everything is visible: ```typescript route.post("/users", { resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); // ↑ Request ends here, returns 400 } if (!c.locals.user) { return Response.json({ error: "Unauthorized" }, { status: 401 }); // ↑ Request ends here, returns 401 } if (c.locals.user.role !== "admin") { return Response.json({ error: "Forbidden" }, { status: 403 }); // ↑ Request ends here, returns 403 } // If we're here, all checks passed const result = await process(c.input.body); return Response.json(result, { status: 201 }); // ↑ Request ends here, returns 201 } }) ``` Every `return` is visible. You can trace the control flow. You can see exactly what responses this handler returns. ## Modeling failure Different types of failures need different responses. ### Invalid input When the client sends malformed or invalid data: ```typescript route.post("/users", { request: { body: z.object({ name: z.string().min(1).max(100), email: z.string().email(), age: z.number().int().min(0).max(150) }) }, resolve: (c) => { if (!c.input.ok) { return Response.json( { error: "Validation failed", issues: c.input.issues }, { status: 400 } ); } // Input is valid const user = await db.users.create(c.input.body); return Response.json(user, { status: 201 }); } }) ``` **Status code**: `400 Bad Request` **Body**: Explains what's wrong with the input: ```json { "error": "Validation failed", "issues": [ { "part": "body", "path": ["email"], "message": "Invalid email address" }, { "part": "body", "path": ["age"], "message": "Number must be less than or equal to 150" } ] } ``` ### Unauthorized access When the client isn't authenticated: ```typescript const requireAuth: GuardFn = (c) => { const token = c.request.headers.get("authorization"); if (!token) { return { deny: Response.json( { error: "Missing authentication token" }, { status: 401 } ) }; } const user = verifyToken(token); if (!user) { return { deny: Response.json( { error: "Invalid or expired token" }, { status: 401 } ) }; } return { allow: true, locals: { user } }; }; ``` **Status code**: `401 Unauthorized` **Body**: Explains why authentication failed: ```json { "error": "Invalid or expired token" } ``` You might also include helpful information: ```json { "error": "Missing authentication token", "hint": "Include an Authorization header with a valid token", "docs": "https://example.com/docs/authentication" } ``` ### Forbidden access When the client is authenticated but not authorized: ```typescript const requireAdmin: GuardFn = (c) => { const user = c.locals.user; if (!user || user.role !== "admin") { return { deny: Response.json( { error: "Admin access required" }, { status: 403 } ) }; } return { allow: true }; }; ``` **Status code**: `403 Forbidden` **Body**: Explains what permission is required: ```json { "error": "Admin access required" } ``` ### Not found When the requested resource doesn't exist: ```typescript 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); } }) ``` **Status code**: `404 Not Found` **Body**: Explains what wasn't found: ```json { "error": "User not found" } ``` You might include the ID that wasn't found: ```json { "error": "User not found", "userId": "123" } ``` ### Conflict When the request conflicts with current state: ```typescript route.post("/users", { resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const { email } = c.input.body; const existing = await db.users.findByEmail(email); if (existing) { return Response.json( { error: "Email already exists", field: "email", value: email }, { status: 409 } ); } const user = await db.users.create(c.input.body); return Response.json(user, { status: 201 }); } }) ``` **Status code**: `409 Conflict` **Body**: Explains the conflict: ```json { "error": "Email already exists", "field": "email", "value": "alice@example.com" } ``` ### Unprocessable entity When the input is valid but semantically incorrect: ```typescript route.post("/transfers", { request: { body: z.object({ fromAccount: z.string(), toAccount: z.string(), amount: z.number().positive() }) }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const { fromAccount, toAccount, amount } = c.input.body; // Input is valid, but business rules say this is wrong if (fromAccount === toAccount) { return Response.json( { error: "Cannot transfer to the same account" }, { status: 422 } ); } const balance = await db.accounts.getBalance(fromAccount); if (balance < amount) { return Response.json( { error: "Insufficient funds", available: balance, requested: amount }, { status: 422 } ); } const transfer = await db.transfers.create({ fromAccount, toAccount, amount }); return Response.json(transfer, { status: 201 }); } }) ``` **Status code**: `422 Unprocessable Entity` **Body**: Explains why the business logic rejected it: ```json { "error": "Insufficient funds", "available": 100.50, "requested": 200.00 } ``` ### Internal errors When something unexpected happens (bugs, infrastructure failures): ```typescript route.get("/users", { resolve: async (c) => { try { const users = await db.users.getAll(); return Response.json(users); } catch (error) { // Log the error console.error("Failed to fetch users:", error); // Return generic error to client return Response.json( { error: "Failed to fetch users" }, { status: 500 } ); } } }) ``` **Or let it throw and handle in onError**: ```typescript const app = setup({ handlers: [...], onError: ({ error, context }) => { // Log with context console.error("Unexpected error:", { error, method: context.request.method, url: context.request.url, user: context.locals.user?.id }); // Return sanitized error to client return Response.json( { error: "Internal server error", requestId: context.locals.requestId }, { status: 500 } ); } }); ``` **Status code**: `500 Internal Server Error` **Body**: Generic message (don't leak implementation details): ```json { "error": "Internal server error", "requestId": "abc-123" } ``` **Never** expose stack traces or internal details to clients in production. ## Returning errors explicitly Errors are just Responses. Return them like any other response. ### Status codes as facts Status codes communicate **what kind of failure** occurred: | Code | Meaning | When to Use | |------|---------|-------------| | `400` | Bad Request | Client sent invalid/malformed data | | `401` | Unauthorized | Client needs to authenticate | | `403` | Forbidden | Client is authenticated but not authorized | | `404` | Not Found | Resource doesn't exist | | `409` | Conflict | Request conflicts with current state | | `422` | Unprocessable Entity | Valid input, but business rules reject it | | `429` | Too Many Requests | Rate limit exceeded | | `500` | Internal Server Error | Unexpected server failure | | `503` | Service Unavailable | Server temporarily can't handle request | Use the right status code for the situation: ```typescript route.post("/users", { resolve: async (c) => { // 400: Invalid input structure if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // 401: Not authenticated if (!c.locals.user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } // 403: Authenticated but not authorized if (c.locals.user.role !== "admin") { return Response.json({ error: "Admin access required" }, { status: 403 }); } // 409: Duplicate resource const existing = await db.users.findByEmail(c.input.body.email); if (existing) { return Response.json({ error: "Email already exists" }, { status: 409 }); } // 201: Successfully created const user = await db.users.create(c.input.body); return Response.json(user, { status: 201 }); } }) ``` **Each status code tells a different story.** Choose the one that best describes what happened. ### Bodies as Explanations The response body explains **what went wrong** and **what to do about it**. **Minimal**: ```typescript return Response.json( { error: "Not found" }, { status: 404 } ); ``` **Detailed**: ```typescript return Response.json( { error: "User not found", userId: id, message: "No user exists with the specified ID" }, { status: 404 } ); ``` **With suggestions**: ```typescript return Response.json( { error: "Invalid email format", field: "email", received: "not-an-email", hint: "Email must be in format: user@example.com" }, { status: 400 } ); ``` **With error codes**: ```typescript return Response.json( { error: "Insufficient funds", code: "INSUFFICIENT_FUNDS", available: 100.50, requested: 200.00, hint: "Deposit more funds or reduce the transfer amount" }, { status: 422 } ); ``` **With links**: ```typescript return Response.json( { error: "Rate limit exceeded", code: "RATE_LIMIT_EXCEEDED", limit: 100, remaining: 0, resetAt: "2024-01-01T00:00:00Z", docs: "https://example.com/docs/rate-limits" }, { status: 429 } ); ``` ### Consistent error format Choose a format and stick to it across your API: **Simple format**: ```typescript interface ErrorResponse { error: string; details?: unknown; } // Usage return Response.json( { error: "User not found", details: { userId: id } }, { status: 404 } ); ``` **Detailed format**: ```typescript interface ErrorResponse { error: { code: string; message: string; details?: Record; hint?: string; }; } // Usage return Response.json( { error: { code: "USER_NOT_FOUND", message: "No user exists with the specified ID", details: { userId: id }, hint: "Check that the user ID is correct" } }, { status: 404 } ); ``` **JSON:API format** (if you're following that spec): ```typescript return Response.json( { errors: [ { status: "404", code: "USER_NOT_FOUND", title: "User not found", detail: "No user exists with the specified ID", meta: { userId: id } } ] }, { status: 404 } ); ``` **Problem Details (RFC 7807)**: ```typescript return new Response( JSON.stringify({ type: "https://example.com/problems/user-not-found", title: "User Not Found", status: 404, detail: "No user exists with the specified ID", instance: `/users/${id}`, userId: id }), { status: 404, headers: { "Content-Type": "application/problem+json" } } ); ``` **Hectoday HTTP doesn't enforce a format.** Choose what works for your API and use it consistently. ### Helper functions Reduce repetition with error helpers: ```typescript // helpers/errors.ts export function badRequest(message: string, details?: unknown) { return Response.json( { error: message, details }, { status: 400 } ); } export function unauthorized(message: string = "Unauthorized") { return Response.json( { error: message }, { status: 401 } ); } export function forbidden(message: string = "Forbidden") { return Response.json( { error: message }, { status: 403 } ); } export function notFound(message: string, details?: unknown) { return Response.json( { error: message, details }, { status: 404 } ); } export function conflict(message: string, details?: unknown) { return Response.json( { error: message, details }, { status: 409 } ); } // Usage route.get("/users/:id", { resolve: async (c) => { const id = c.raw.params.id; if (!id) { return badRequest("Missing user ID"); } const user = await db.users.get(id); if (!user) { return notFound("User not found", { userId: id }); } return Response.json(user); } }) ``` **These are just helpers, not framework magic.** They're pure functions that return Responses. ### The pattern ``` Something fails ↓ Determine the type of failure ↓ Choose appropriate status code ↓ Construct helpful response body ↓ Return Response explicitly ``` **No throwing. No exception mapping. Just explicit returns.** Every error response is visible in the handler. You control exactly what clients see. --- Next: [Composition over configuration](./composition-over-configuration) - building larger APIs from small pieces. ================================================================================ ## Composition over configuration > Building larger APIs from small, reusable pieces > Source: /docs/composition-over-configuration ================================================================================ Small APIs are easy. One file, a few routes, done. Large APIs need structure. But structure doesn't mean configuration files, decorators, or magic conventions. In Hectoday HTTP, structure comes from **composition**, building larger pieces from smaller ones. ## Building larger APIs As your API grows, you need to organize routes, share guards, and reuse validation logic. ### Composing handlers Start simple: ```typescript // routes/users.ts import { route } from "@hectoday/http"; export const getUsers = route.get("/users", { resolve: async () => { const users = await db.users.getAll(); return Response.json(users); } }); export const getUser = 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 }); } return Response.json(user); } }); export const createUser = route.post("/users", { request: { body: z.object({ name: z.string(), email: z.string().email() }) }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const user = await db.users.create(c.input.body); return Response.json(user, { status: 201 }); } }); ``` Collect into an array: ```typescript // routes/users.ts export const userRoutes = [ getUsers, getUser, createUser ]; ``` Compose in main file: ```typescript // main.ts import { setup } from "@hectoday/http"; import { userRoutes } from "./routes/users.ts"; import { postRoutes } from "./routes/posts.ts"; import { commentRoutes } from "./routes/comments.ts"; const app = setup({ handlers: [ ...userRoutes, ...postRoutes, ...commentRoutes ] }); Deno.serve(app.fetch); ``` **That's it.** Routes are just arrays. Composition is just spread operators. No magic. ### Grouping with shared guards Use `group()` to apply guards to multiple routes: ```typescript // routes/admin.ts import { group, route } from "@hectoday/http"; import { requireAuth, requireAdmin } from "../guards.ts"; const adminUsers = route.get("/admin/users", { resolve: async () => { const users = await db.users.getAll(); return Response.json(users); } }); const deleteUser = route.delete("/admin/users/:id", { resolve: async (c) => { await db.users.delete(c.raw.params.id); return new Response(null, { status: 204 }); } }); const updateSettings = route.put("/admin/settings", { request: { body: settingsSchema }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } await db.settings.update(c.input.body); return Response.json({ updated: true }); } }); // Apply guards to all routes export const adminRoutes = group({ guards: [requireAuth, requireAdmin], handlers: [ adminUsers, deleteUser, updateSettings ] }); ``` **What `group()` does**: ```typescript // Each route in the group gets the guards prepended adminUsers.guards = [requireAuth, requireAdmin, ...adminUsers.guards] deleteUser.guards = [requireAuth, requireAdmin, ...deleteUser.guards] updateSettings.guards = [requireAuth, requireAdmin, ...updateSettings.guards] ``` It's **build-time composition**. No runtime overhead. No hidden behavior. ### Nested groups Groups can contain groups: ```typescript // routes/api.ts import { group } from "@hectoday/http"; import { requireAuth } from "../guards.ts"; import { adminRoutes } from "./admin.ts"; import { userRoutes } from "./users.ts"; // All authenticated routes export const apiRoutes = group({ guards: [requireAuth], handlers: [ ...userRoutes, // Gets [requireAuth, ...userRoutes.guards] ...adminRoutes // Gets [requireAuth, requireAdmin, ...adminRoutes.guards] ] }); ``` **Guard order**: Outer group guards run first, then inner group guards, then route-specific guards. ```typescript // For a route in adminRoutes: // 1. requireAuth (from apiRoutes) // 2. requireAdmin (from adminRoutes) // 3. Route-specific guards (if any) ``` ### Reusing guards Guards are just functions. Export and reuse them: ```typescript // guards/auth.ts import type { GuardFn } from "@hectoday/http"; export 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 } }; }; export const requireAdmin: GuardFn = (c) => { const user = c.locals.user; if (!user || user.role !== "admin") { return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) }; } return { allow: true }; }; export const requireEmailVerified: GuardFn = (c) => { const user = c.locals.user; if (!user?.emailVerified) { return { deny: Response.json({ error: "Email not verified" }, { status: 403 }) }; } return { allow: true }; }; ``` Use across routes: ```typescript // routes/profile.ts import { requireAuth, requireEmailVerified } from "../guards/auth.ts"; export const profileRoutes = [ route.get("/profile", { guards: [requireAuth], resolve: (c) => Response.json(c.locals.user) }), route.put("/profile", { guards: [requireAuth, requireEmailVerified], resolve: async (c) => { // Update profile } }) ]; ``` ```typescript // routes/admin.ts import { requireAuth, requireAdmin } from "../guards/auth.ts"; export const adminRoutes = group({ guards: [requireAuth, requireAdmin], handlers: [/* admin routes */] }); ``` **Same guards, different contexts.** Pure functions, no magic. ### Parameterized guards Create guard factories for flexible reuse: ```typescript // guards/permissions.ts import type { GuardFn } from "@hectoday/http"; export const requireRole = (role: string): GuardFn => { return (c) => { const user = c.locals.user; if (!user || user.role !== role) { return { deny: Response.json( { error: `${role} role required` }, { status: 403 } ) }; } return { allow: true }; }; }; export const requirePermission = (permission: string): GuardFn => { return (c) => { const user = c.locals.user; if (!user?.permissions?.includes(permission)) { return { deny: Response.json( { error: `Missing permission: ${permission}` }, { status: 403 } ) }; } return { allow: true }; }; }; export const requireOwnership = ( resourceGetter: (c: Context) => Promise<{ ownerId: string } | null> ): GuardFn => { return async (c) => { const user = c.locals.user; const resource = await resourceGetter(c); if (!resource) { return { deny: Response.json({ error: "Not found" }, { status: 404 }) }; } if (resource.ownerId !== user.id) { return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) }; } return { allow: true, locals: { resource } }; }; }; ``` Use with different parameters: ```typescript route.get("/admin", { guards: [requireAuth, requireRole("admin")], resolve: (c) => Response.json({ data: "admin data" }) }); route.get("/moderator", { guards: [requireAuth, requireRole("moderator")], resolve: (c) => Response.json({ data: "mod data" }) }); route.delete("/posts/:id", { guards: [ requireAuth, requirePermission("posts:delete"), requireOwnership(async (c) => { return await db.posts.get(c.raw.params.id); }) ], resolve: async (c) => { await db.posts.delete(c.raw.params.id); return new Response(null, { status: 204 }); } }); ``` **Still explicit.** You see the parameters in the route definition. ### Reusing validators Validators are just schemas. Share them: ```typescript // schemas/user.ts import { z } from "zod"; export const nameSchema = z.string().min(1).max(100); export const emailSchema = z.string().email(); export const passwordSchema = z.string().min(8).max(100); export const userIdSchema = z.string().uuid(); export const createUserSchema = z.object({ name: nameSchema, email: emailSchema, password: passwordSchema }); export const updateUserSchema = z.object({ name: nameSchema.optional(), email: emailSchema.optional() }); export const loginSchema = z.object({ email: emailSchema, password: passwordSchema }); ``` Use across routes: ```typescript // routes/users.ts import { createUserSchema, updateUserSchema } from "../schemas/user.ts"; export const userRoutes = [ route.post("/users", { request: { body: createUserSchema }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const user = await db.users.create(c.input.body); return Response.json(user, { status: 201 }); } }), route.patch("/users/:id", { request: { body: updateUserSchema }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const user = await db.users.update(c.raw.params.id, c.input.body); return Response.json(user); } }) ]; ``` ```typescript // routes/auth.ts import { loginSchema } from "../schemas/user.ts"; export const authRoutes = [ route.post("/login", { request: { body: loginSchema }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const token = await authenticate(c.input.body); return Response.json({ token }); } }) ]; ``` **Same schemas, different routes.** Data structures, not configuration. ### Composition patterns **By feature**: ``` src/ features/ users/ routes.ts guards.ts schemas.ts posts/ routes.ts guards.ts schemas.ts comments/ routes.ts schemas.ts main.ts ``` **By layer**: ``` src/ routes/ users.ts posts.ts comments.ts guards/ auth.ts permissions.ts schemas/ user.ts post.ts main.ts ``` **Hybrid**: ``` src/ api/ users/ routes.ts schemas.ts posts/ routes.ts schemas.ts guards/ auth.ts # Shared across features permissions.ts main.ts ``` **Choose what works for your team.** Hectoday HTTP doesn't enforce structure. ## Helpers as copy-paste recipes Hectoday HTTP has a minimal core. Everything else is helpers, copy-paste recipes you can use, modify, or ignore. ### Why helpers are documentation, not dependencies **Core framework**: - Route matching - Validation integration - Guard composition - Context threading **That's it.** ~500 lines of code. **Everything else is in the docs as recipes**: - [Zod Validator](./helpers/zod-validator) - Validator adapter for Zod schemas - [Body size limits](./helpers/max-body-bytes) - `maxBodyBytes` guard - [CORS headers](./helpers/cors) - `corsHeaders` response helper - [Request ID tracking](./helpers/request-id) - Request ID generation and headers - [Rate limiting](./helpers/rate-limit) - Rate limit guards These are **recipes, not packages**. Copy the code, paste it into your project, modify it for your needs. **Why not a package?** - No dependency to maintain - No version conflicts - No "tree-shaking" concerns - You own the code - Modify without forking - Copy only what you need ### Example: maxBodyBytes helper See the [full documentation](./helpers/max-body-bytes) for the complete code. **Quick version:** ```typescript // helpers/maxBodyBytes.ts - copied from docs import type { GuardFn } from "@hectoday/http"; const SIZES = { KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024 }; function maxBodyBytes(limit: number): GuardFn { return (c) => { const contentLength = c.request.headers.get("content-length"); if (!contentLength) return { allow: true }; const size = parseInt(contentLength, 10); if (size > limit) { return { deny: Response.json( { error: "Request body too large", limit, received: size }, { status: 413 } ) }; } return { allow: true }; }; } ``` **Use it:** ```typescript // Import from YOUR project (you copied it) import { maxBodyBytes, SIZES } from "./helpers/maxBodyBytes.ts"; route.post("/upload", { guards: [maxBodyBytes(10 * SIZES.MB)], resolve: async (c) => { const data = await c.request.arrayBuffer(); return Response.json({ size: data.byteLength }); } }); ``` **Modify it for your needs:** ```typescript // Your custom version const maxBodyBytesCustom = (limit: number): GuardFn => { return async (c) => { const contentLength = c.request.headers.get("content-length"); if (!contentLength) { // Different behavior: reject missing Content-Length return { deny: Response.json( { error: "Content-Length header required" }, { status: 411 } ) }; } const size = parseInt(contentLength, 10); if (size > limit) { // Different error format return { deny: new Response("Body too large", { status: 413 }) }; } return { allow: true }; }; }; ``` **Or don't use it at all**. It's optional. ### More helper examples **CORS Headers** - See [full docs](./helpers/cors) ```typescript // Copy from docs, use in onResponse 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 }); } ``` **Request ID Tracking** - See [full docs](./helpers/request-id) ```typescript // Copy from docs, use in onRequest + onResponse onRequest: ({ request }) => ({ 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 }); } ``` **Rate Limiting** - See [full docs](./helpers/rate-limit) ```typescript // Copy from docs, use as guard guards: [rateLimit({ maxRequests: 100, windowMs: 60_000 })] ``` ### Why copy-paste? **No dependency bloat:** ```typescript // Your project import { maxBodyBytes } from "./helpers/maxBodyBytes.ts"; // You copied only what you need // No external dependencies // No version conflicts // No tree-shaking concerns ``` **Compare to monolithic frameworks:** ```typescript import { Framework } from "big-framework"; // Bundle includes: // - CORS middleware (you don't use) // - Session middleware (you don't use) // - Cookie parser (you don't use) // - Static file server (you don't use) // - 50 other things you don't use ``` **With copy-paste helpers:** - You own the code - Modify without forking - No dependency updates to track - No breaking changes from maintainers - Copy only what you need Hectoday HTTP: **core is ~500 lines, helpers are documentation**. ### Writing your own helpers Helpers are just functions. Write your own: ```typescript // helpers/rateLimit.ts import type { GuardFn } from "@hectoday/http"; const rateLimits = new Map(); export const rateLimit = ( maxRequests: number, windowMs: number, keyFn: (c: Context) => string = (c) => c.request.headers.get("x-forwarded-for") || "unknown" ): GuardFn => { return (c) => { const key = keyFn(c); const now = Date.now(); const record = rateLimits.get(key); if (!record || now > record.resetAt) { rateLimits.set(key, { count: 1, resetAt: now + windowMs }); return { allow: true }; } if (record.count >= maxRequests) { return { deny: Response.json( { error: "Rate limit exceeded", limit: maxRequests, resetAt: new Date(record.resetAt).toISOString() }, { status: 429 } ) }; } record.count++; return { allow: true }; }; }; ``` Use it: ```typescript route.post("/api/search", { guards: [ rateLimit(100, 60_000) // 100 requests per minute ], resolve: async (c) => { // Handle search } }); ``` **It's just a guard.** Nothing special about helpers, they use the same primitives you use. ### The philosophy **Core**: Minimal, stable, never changes **Helpers**: Optional, composable, evolve based on needs **Your code**: Builds on both, owns the decisions ``` Your API ↓ Helpers (optional recipes) ↓ Core (minimal primitives) ↓ Web Standards (Request/Response) ``` **Small core, infinite flexibility.** --- Next: [Security as a first-class concept](./security-as-a-first-class-concept) - designing secure APIs with explicit controls. # PART 4: REAL CONCERNS ================================================================================ ## Security as a first-class concept > Designing secure APIs with explicit security controls > Source: /docs/security-as-a-first-class-concept ================================================================================ Security isn't an afterthought. It's not middleware you add later. It's a core part of your API design. In Hectoday HTTP, security is **explicit decisions** visible in your code. ## Security is a decision, not middleware Most frameworks treat security as middleware: ```typescript // In many frameworks app.use(helmet()); app.use(rateLimiter()); app.use(validateContentType()); app.use(checkOrigin()); app.post("/api/users", handler); // Which security checks apply? // In what order? // Can they be bypassed? // What happens if they fail? ``` This creates **security through obscurity**. You don't see the checks when reading routes. You don't know the order. You don't know what responses they return. ### Why security belongs in guards Guards make security decisions **explicit**: ```typescript route.post("/api/users", { guards: [ requireAuth, validateContentType(["application/json"]), maxBodyBytes(1 * MB), rateLimit(100, 60_000) ], request: { body: createUserSchema }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const user = await db.users.create(c.input.body); return Response.json(user, { status: 201 }); } }) ``` **Reading this route tells you**: 1. User must be authenticated (`requireAuth`) 2. Content-Type must be JSON (`validateContentType`) 3. Body must be under 1MB (`maxBodyBytes`) 4. Rate limited to 100 req/min (`rateLimit`) 5. Body validated against schema 6. If all pass, user is created **Security is visible.** You can audit it by reading the route definition. ### The difference **Middleware approach** (implicit): ```typescript // Somewhere in middleware setup app.use(requireAuth); // Later, in a route file app.post("/users", handler); // Does this route require auth? // You have to check middleware config. ``` **Guard approach** (explicit): ```typescript route.post("/users", { guards: [requireAuth], resolve: handler }) // Does this route require auth? // Yes. It's right there. ``` **Security decisions are part of the route definition.** Not global config. Not hidden middleware. ## Common security guards Let's build security guards for common threats. ### Authentication We've seen this before, but it's fundamental: ```typescript import type { GuardFn } from "@hectoday/http"; export const requireAuth: GuardFn = (c) => { const token = c.request.headers.get("authorization"); if (!token) { return { deny: Response.json( { error: "Missing authentication token" }, { status: 401 } ) }; } const user = verifyToken(token); if (!user) { return { deny: Response.json( { error: "Invalid or expired token" }, { status: 401 } ) }; } return { allow: true, locals: { user, userId: user.id } }; }; ``` **Applies to**: Any route that requires authentication **Protects against**: Unauthorized access ### Content-Type validation Prevent attacks that exploit content type confusion: ```typescript import type { GuardFn } from "@hectoday/http"; export const validateContentType = (allowed: string[]): GuardFn => { return (c) => { const contentType = c.request.headers.get("content-type"); if (!contentType) { return { deny: Response.json( { error: "Content-Type header required" }, { status: 400 } ) }; } // Parse content type (ignore charset, etc.) const mediaType = contentType.split(";")[0].trim().toLowerCase(); if (!allowed.includes(mediaType)) { return { deny: Response.json( { error: "Unsupported Content-Type", allowed, received: mediaType }, { status: 415 } // Unsupported Media Type ) }; } return { allow: true }; }; }; ``` **Usage**: ```typescript route.post("/api/users", { guards: [ requireAuth, validateContentType(["application/json"]) ], resolve: async (c) => { // Content-Type is guaranteed to be application/json const data = await c.request.json(); return Response.json(data); } }) ``` **Applies to**: Routes that expect specific content types **Protects against**: Content type confusion attacks, unexpected data formats ### Body size limits Prevent denial-of-service through large payloads: ```typescript import type { GuardFn } from "@hectoday/http"; export const SIZES = { KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024 }; export const maxBodyBytes = (limit: number): GuardFn => { return (c) => { const contentLength = c.request.headers.get("content-length"); if (!contentLength) { // No Content-Length header - body might be too large // You can choose to reject or allow (streaming) return { allow: true }; } const size = parseInt(contentLength, 10); if (isNaN(size) || size < 0) { return { deny: Response.json( { error: "Invalid Content-Length" }, { status: 400 } ) }; } if (size > limit) { return { deny: Response.json( { error: "Request body too large", limit, received: size }, { status: 413 } // Payload Too Large ) }; } return { allow: true }; }; }; ``` **Usage**: ```typescript route.post("/api/upload", { guards: [ requireAuth, maxBodyBytes(10 * SIZES.MB) // 10MB limit ], resolve: async (c) => { const data = await c.request.arrayBuffer(); // Process upload... return Response.json({ size: data.byteLength }); } }) ``` **Applies to**: Routes that accept request bodies **Protects against**: DoS attacks via large payloads, resource exhaustion ### Rate limiting Prevent abuse through excessive requests: ```typescript import type { GuardFn } from "@hectoday/http"; interface RateLimitRecord { count: number; resetAt: number; } const rateLimits = new Map(); export const rateLimit = ( maxRequests: number, windowMs: number, keyFn: (c: Context) => string = (c) => { // Default: use IP address return c.request.headers.get("x-forwarded-for") || c.request.headers.get("x-real-ip") || "unknown"; } ): GuardFn => { return (c) => { const key = keyFn(c); const now = Date.now(); const record = rateLimits.get(key); // No record or window expired - create new if (!record || now > record.resetAt) { rateLimits.set(key, { count: 1, resetAt: now + windowMs }); return { allow: true }; } // Limit exceeded if (record.count >= maxRequests) { const resetIn = Math.ceil((record.resetAt - now) / 1000); return { deny: new Response( JSON.stringify({ error: "Rate limit exceeded", limit: maxRequests, remaining: 0, resetIn }), { status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(resetIn), "X-RateLimit-Limit": String(maxRequests), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": String(Math.ceil(record.resetAt / 1000)) } } ) }; } // Increment and allow record.count++; return { allow: true, locals: { rateLimit: { limit: maxRequests, remaining: maxRequests - record.count, resetAt: record.resetAt } } }; }; }; ``` **Usage**: ```typescript // Global rate limit for all API routes const apiRoutes = group({ guards: [ rateLimit(1000, 60_000) // 1000 requests per minute ], handlers: [/* routes */] }); // Stricter limit for expensive operations route.post("/api/ai/generate", { guards: [ requireAuth, rateLimit(10, 60_000, (c) => c.locals.userId) // 10 req/min per user ], resolve: async (c) => { // Generate AI content... } }) ``` **Applies to**: All routes, especially expensive operations **Protects against**: Brute force attacks, API abuse, resource exhaustion ### Origin validation Prevent cross-origin attacks: ```typescript import type { GuardFn } from "@hectoday/http"; export const requireOrigin = (allowed: string[]): GuardFn => { return (c) => { const origin = c.request.headers.get("origin"); if (!origin) { // No Origin header - might be same-origin or server-to-server // Decide based on your security requirements return { allow: true }; } if (!allowed.includes(origin)) { return { deny: Response.json( { error: "Origin not allowed", origin, allowedOrigins: allowed }, { status: 403 } ) }; } return { allow: true }; }; }; ``` **Usage**: ```typescript route.post("/api/webhooks/payment", { guards: [ requireOrigin(["https://payment-provider.com"]) ], resolve: async (c) => { // Process webhook... } }) ``` **Applies to**: Webhooks, server-to-server APIs **Protects against**: Cross-origin attacks, unauthorized webhook sources ### CSRF protection Prevent cross-site request forgery: ```typescript import type { GuardFn } from "@hectoday/http"; export const requireCsrfToken: GuardFn = (c) => { // Only check for state-changing methods if (!["POST", "PUT", "PATCH", "DELETE"].includes(c.request.method)) { return { allow: true }; } const tokenFromHeader = c.request.headers.get("x-csrf-token"); const tokenFromCookie = getCookie(c.request, "csrf-token"); if (!tokenFromHeader || !tokenFromCookie) { return { deny: Response.json( { error: "Missing CSRF token" }, { status: 403 } ) }; } // Constant-time comparison to prevent timing attacks if (!timingSafeEqual(tokenFromHeader, tokenFromCookie)) { return { deny: Response.json( { error: "Invalid CSRF token" }, { status: 403 } ) }; } return { allow: true }; }; function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); } return result === 0; } ``` **Usage**: ```typescript route.post("/api/users", { guards: [ requireAuth, requireCsrfToken ], resolve: async (c) => { // CSRF token validated } }) ``` **Applies to**: Routes that modify state (POST, PUT, PATCH, DELETE) **Protects against**: Cross-site request forgery attacks ### API key validation For machine-to-machine authentication: ```typescript import type { GuardFn } from "@hectoday/http"; export const requireApiKey: GuardFn = async (c) => { const apiKey = c.request.headers.get("x-api-key"); if (!apiKey) { return { deny: Response.json( { error: "Missing API key" }, { status: 401 } ) }; } // Verify API key (check database, cache, etc.) const keyData = await db.apiKeys.verify(apiKey); if (!keyData) { return { deny: Response.json( { error: "Invalid API key" }, { status: 401 } ) }; } if (keyData.expiresAt < Date.now()) { return { deny: Response.json( { error: "API key expired" }, { status: 401 } ) }; } // Check permissions return { allow: true, locals: { apiKeyId: keyData.id, apiKeyOwner: keyData.ownerId, apiKeyPermissions: keyData.permissions } }; }; export const requireApiKeyPermission = (permission: string): GuardFn => { return (c) => { const permissions = c.locals.apiKeyPermissions as string[] | undefined; if (!permissions?.includes(permission)) { return { deny: Response.json( { error: `Missing permission: ${permission}` }, { status: 403 } ) }; } return { allow: true }; }; }; ``` **Usage**: ```typescript route.post("/api/data", { guards: [ requireApiKey, requireApiKeyPermission("data:write") ], resolve: async (c) => { // API key validated and has write permission } }) ``` **Applies to**: Machine-to-machine APIs, third-party integrations **Protects against**: Unauthorized API access ### Request IDs Not a security control per se, but critical for security auditing: ```typescript import type { GuardFn } from "@hectoday/http"; export const attachRequestId: GuardFn = (c) => { // Check if client provided request ID const requestId = c.request.headers.get("x-request-id") || crypto.randomUUID(); return { allow: true, locals: { requestId } }; }; ``` **Or use in `onRequest` hook**: ```typescript const app = setup({ handlers: [...], onRequest: ({ request }) => { const requestId = request.headers.get("x-request-id") || crypto.randomUUID(); return { requestId, startTime: Date.now() }; }, onResponse: ({ context, response }) => { const headers = new Headers(response.headers); headers.set("X-Request-Id", String(context.locals.requestId)); // Log for audit trail console.log({ requestId: context.locals.requestId, method: context.request.method, path: new URL(context.request.url).pathname, status: response.status, duration: Date.now() - (context.locals.startTime as number), userId: context.locals.user?.id }); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } }); ``` **Applies to**: All routes **Enables**: Request tracing, security auditing, debugging ### Input sanitization Prevent injection attacks: ```typescript import type { GuardFn } from "@hectoday/http"; export const sanitizeInput: GuardFn = async (c) => { // This is tricky - body is a stream and can only be read once // Better to do sanitization in validation schemas or handlers // But for query params, you can sanitize: const suspiciousPattern = / !/ { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // Input is validated and sanitized const comment = await db.comments.create(c.input.body); return Response.json(comment, { status: 201 }); } }) ``` **Applies to**: Routes accepting user input **Protects against**: XSS, SQL injection (when combined with parameterized queries) ## Auditing the request path The most important security feature of Hectoday HTTP isn't a guard, it's **visibility**. ### Why explicit flow is easier to reason about When security is explicit, auditing is straightforward: ```typescript route.delete("/admin/users/:id", { guards: [ requireAuth, // 1. Must be authenticated requireAdmin, // 2. Must be admin requireEmailVerified, // 3. Email must be verified requireNotSelf // 4. Can't delete yourself ], resolve: async (c) => { // If we're here, all four checks passed const id = c.raw.params.id; await db.users.delete(id); return new Response(null, { status: 204 }); } }) ``` **Security audit questions**: Q: Can unauthenticated users delete users? A: No. `requireAuth` denies them. Q: Can regular users delete users? A: No. `requireAdmin` denies them. Q: Can admins delete users with unverified emails? A: No. `requireEmailVerified` denies them. Q: Can admins delete themselves? A: No. `requireNotSelf` denies them. **All answers are in the route definition.** No hidden middleware. No global config. Just explicit guards. ### Tracing security decisions For any request, you can trace exactly what happened: ```typescript const app = setup({ handlers: [...], onRequest: ({ request }) => { return { requestId: crypto.randomUUID(), securityLog: [] }; }, // Wrap guards to log decisions handlers: handlers.map(handler => ({ ...handler, guards: handler.guards?.map(guard => { return (c: Context) => { const result = guard(c); if (result.deny) { (c.locals.securityLog as string[]).push( `Guard denied: ${guard.name || "anonymous"}` ); } else { (c.locals.securityLog as string[]).push( `Guard allowed: ${guard.name || "anonymous"}` ); } return result; }; }) })), onResponse: ({ context, response }) => { console.log({ requestId: context.locals.requestId, path: new URL(context.request.url).pathname, status: response.status, securityLog: context.locals.securityLog }); return response; } }); ``` **Log output**: ``` { requestId: "abc-123", path: "/admin/users/456", status: 403, securityLog: [ "Guard allowed: requireAuth", "Guard denied: requireAdmin" ] } ``` You can see **exactly** which guard denied the request. ### Security reviews When reviewing code for security issues, you read route definitions: ```typescript // Is this route secure? route.post("/api/transfer", { guards: [ requireAuth, validateContentType(["application/json"]), maxBodyBytes(1 * KB), rateLimit(10, 60_000) ], request: { body: transferSchema }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const { fromAccount, toAccount, amount } = c.input.body; // Check ownership if (fromAccount !== c.locals.user.accountId) { return Response.json({ error: "Forbidden" }, { status: 403 }); } // Check balance const balance = await db.accounts.getBalance(fromAccount); if (balance < amount) { return Response.json({ error: "Insufficient funds" }, { status: 422 }); } // Execute transfer const transfer = await db.transfers.create({ fromAccount, toAccount, amount, initiatedBy: c.locals.userId }); return Response.json(transfer, { status: 201 }); } }) ``` **Review checklist**: - ✓ Requires authentication - ✓ Validates content type - ✓ Limits body size - ✓ Rate limited - ✓ Input validated (transferSchema) - ✓ Checks account ownership - ✓ Checks sufficient balance - ✓ Logs who initiated transfer **Everything is visible.** No hidden middleware to check. No global config to audit. ### No hidden bypasses Common middleware problem: ```typescript // In middleware config app.use("/api/*", requireAuth); // Later, someone adds a route app.get("/api/public-data", handler); // Oops! This route requires auth when it shouldn't // Or: middleware order changes, auth gets skipped ``` With explicit guards, bypasses are impossible: ```typescript route.get("/api/public-data", { // No guards - publicly accessible (intentionally) resolve: async () => { const data = await db.getPublicData(); return Response.json(data); } }) route.get("/api/private-data", { guards: [requireAuth], // Explicitly protected resolve: async (c) => { const data = await db.getPrivateData(c.locals.userId); return Response.json(data); } }) ``` **Each route declares its own security.** No route can accidentally inherit or skip security checks. ### Defense in Depth Layer security controls: ```typescript // Layer 1: Global guards via group const apiRoutes = group({ guards: [ rateLimit(1000, 60_000), attachRequestId ], handlers: [ // Layer 2: Feature-specific guards group({ guards: [requireAuth], handlers: [ // Layer 3: Route-specific guards route.delete("/users/:id", { guards: [requireAdmin, requireNotSelf], resolve: async (c) => { // Layers 1, 2, and 3 all passed await db.users.delete(c.raw.params.id); return new Response(null, { status: 204 }); } }) ] }) ] }); ``` **Security layers**: 1. Rate limiting (all routes) 2. Authentication (feature-specific) 3. Authorization (route-specific) **All explicit. All visible. All auditable.** --- Next: [Static files and assets](./static-files-and-assets) - serving files while maintaining explicit control. ================================================================================ ## Static files and assets > Serving static files explicitly without magic conventions > Source: /docs/static-files-and-assets ================================================================================ Your API might need to serve static files: HTML pages, images, CSS, JavaScript bundles, or other assets. In Hectoday HTTP, static files are just HTTP responses. Nothing special. ## Static files are still HTTP A static file response is identical to any other response: ```typescript route.get("/index.html", { resolve: async () => { const file = await Deno.readFile("./public/index.html"); return new Response(file, { headers: { "Content-Type": "text/html" } }); } }) ``` **That's it.** Read the file, return a Response. ### Why they're not special Many frameworks have "special" static file handling: ```typescript // In many frameworks app.static("/public", "./public"); // What does this do? // - Maps URLs how? // - Sets what headers? // - Handles errors how? // - Respects guards? // Magic! ``` This is **implicit**. You don't control the mapping. You don't control the headers. You don't know what happens. Hectoday HTTP has no special static file handling. Files are responses: ```typescript route.get("/logo.png", { resolve: async () => { const file = await Deno.readFile("./assets/logo.png"); return new Response(file, { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=31536000" // 1 year } }); } }) ``` **Explicit mapping. Explicit headers. Just like any other route.** ### Files are just bytes A Response body can be: - String - ArrayBuffer - Blob - ReadableStream - **File contents** ```typescript // String return new Response("Hello World"); // File contents const fileBytes = await Deno.readFile("./file.txt"); return new Response(fileBytes); // Same thing! ``` No special file handling. Just bytes in a Response. ## Serving files explicitly Let's build file serving from first principles. ### Single file Serve one specific file: ```typescript route.get("/", { resolve: async () => { const html = await Deno.readTextFile("./public/index.html"); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } }) ``` **Full control**: You chose the URL (`"/"`), the file path (`"./public/index.html"`), and the Content-Type. ### Multiple files with pattern matching Serve files from a directory: ```typescript route.get("/assets/:filename", { resolve: async (c) => { const filename = c.raw.params.filename; // Security: validate filename if (!filename || filename.includes("..") || filename.includes("/")) { return Response.json({ error: "Invalid filename" }, { status: 400 }); } const filePath = `./public/assets/${filename}`; try { const file = await Deno.readFile(filePath); // Determine content type const contentType = getContentType(filename); return new Response(file, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=3600" } }); } catch (error) { if (error instanceof Deno.errors.NotFound) { return Response.json({ error: "File not found" }, { status: 404 }); } console.error("Error reading file:", error); return Response.json({ error: "Internal error" }, { status: 500 }); } } }) function getContentType(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase(); switch (ext) { case "html": return "text/html; charset=utf-8"; case "css": return "text/css; charset=utf-8"; case "js": return "application/javascript; charset=utf-8"; case "json": return "application/json; charset=utf-8"; case "png": return "image/png"; case "jpg": case "jpeg": return "image/jpeg"; case "gif": return "image/gif"; case "svg": return "image/svg+xml"; case "ico": return "image/x-icon"; case "woff": return "font/woff"; case "woff2": return "font/woff2"; default: return "application/octet-stream"; } } ``` **What you control**: - URL pattern (`"/assets/:filename"`) - File path mapping (`"./public/assets/${filename}"`) - Security validation (no `..` or `/`) - Content-Type logic - Cache headers - Error responses **No magic. Every decision is in your code.** ### Nested paths Serve files with directory structure: ```typescript route.get("/static/*", { resolve: async (c) => { const url = new URL(c.request.url); // Extract path after "/static/" const requestPath = url.pathname.slice("/static/".length); // Security: validate path if (requestPath.includes("..")) { return Response.json({ error: "Invalid path" }, { status: 400 }); } // Map to file system const filePath = `./public/${requestPath}`; try { const file = await Deno.readFile(filePath); const contentType = getContentType(requestPath); return new Response(file, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=3600" } }); } catch (error) { if (error instanceof Deno.errors.NotFound) { return Response.json({ error: "File not found" }, { status: 404 }); } return Response.json({ error: "Internal error" }, { status: 500 }); } } }) // GET /static/css/main.css → ./public/css/main.css // GET /static/js/app.js → ./public/js/app.js // GET /static/images/logo.png → ./public/images/logo.png ``` **Wildcard matching** (`"*"`) captures the rest of the path. ### Security: path traversal prevention **Always validate file paths**: ```typescript route.get("/files/*", { resolve: async (c) => { const url = new URL(c.request.url); const requestPath = url.pathname.slice("/files/".length); // ❌ NEVER DO THIS (path traversal vulnerability) // const filePath = `./data/${requestPath}`; // GET /files/../../../etc/passwd → ./data/../../../etc/passwd // ✅ Validate path if ( !requestPath || requestPath.includes("..") || requestPath.startsWith("/") || requestPath.includes("\0") ) { return Response.json({ error: "Invalid path" }, { status: 400 }); } // Additional check: resolve to absolute path and verify it's in allowed directory const basePath = new URL("./data", import.meta.url).pathname; const filePath = new URL(requestPath, `file://${basePath}/`).pathname; if (!filePath.startsWith(basePath)) { return Response.json({ error: "Path not allowed" }, { status: 403 }); } // Now safe to read try { const file = await Deno.readFile(filePath); return new Response(file); } catch (error) { if (error instanceof Deno.errors.NotFound) { return Response.json({ error: "Not found" }, { status: 404 }); } return Response.json({ error: "Internal error" }, { status: 500 }); } } }) ``` **Path traversal is a serious vulnerability.** Always validate and sanitize file paths. ### Runtime-specific optimizations Different runtimes have optimized file serving: #### Deno ```typescript route.get("/files/:filename", { resolve: async (c) => { const filename = c.raw.params.filename; // Validate filename... try { // Use Deno's optimized file reading const file = await Deno.open(`./public/${filename}`, { read: true }); return new Response(file.readable, { headers: { "Content-Type": getContentType(filename) } }); } catch (error) { if (error instanceof Deno.errors.NotFound) { return Response.json({ error: "Not found" }, { status: 404 }); } throw error; } } }) ``` **`file.readable`** is a `ReadableStream` that streams the file without loading it entirely into memory. #### Bun ```typescript route.get("/files/:filename", { resolve: async (c) => { const filename = c.raw.params.filename; // Validate filename... // Bun.file() is highly optimized const file = Bun.file(`./public/${filename}`); if (!(await file.exists())) { return Response.json({ error: "Not found" }, { status: 404 }); } // Bun automatically uses sendfile() syscall when returning Bun.file() return new Response(file, { headers: { "Content-Type": getContentType(filename) } }); } }) ``` **Bun's `file()`** uses zero-copy file serving when possible. #### Cloudflare Workers Workers don't have file systems. Serve from KV or R2: ```typescript route.get("/assets/:filename", { resolve: async (c, env) => { const filename = c.raw.params.filename; // Fetch from R2 bucket const object = await env.ASSETS.get(filename); if (!object) { return Response.json({ error: "Not found" }, { status: 404 }); } return new Response(object.body, { headers: { "Content-Type": getContentType(filename), "Cache-Control": "public, max-age=31536000", "ETag": object.httpEtag } }); } }) ``` **Each runtime has its own optimizations.** Use them when appropriate. ## Mapping URLs to files You control the URL-to-file mapping explicitly. ### Direct mapping ```typescript // URL: /index.html → File: ./public/index.html route.get("/index.html", { resolve: async () => { const file = await Deno.readFile("./public/index.html"); return new Response(file, { headers: { "Content-Type": "text/html" } }); } }) ``` ### Strip prefix ```typescript // URL: /static/logo.png → File: ./public/logo.png route.get("/static/:filename", { resolve: async (c) => { const filename = c.raw.params.filename; const file = await Deno.readFile(`./public/${filename}`); return new Response(file); } }) ``` ### Add prefix ```typescript // URL: /logo.png → File: ./assets/images/logo.png route.get("/:filename", { resolve: async (c) => { const filename = c.raw.params.filename; const file = await Deno.readFile(`./assets/images/${filename}`); return new Response(file); } }) ``` ### Custom logic ```typescript // URL: /v2/api.js → File: ./dist/api.v2.js route.get("/:version/:filename", { resolve: async (c) => { const { version, filename } = c.raw.params; // Custom mapping logic const filePath = `./dist/${filename}.${version}.js`; const file = await Deno.readFile(filePath); return new Response(file, { headers: { "Content-Type": "application/javascript" } }); } }) ``` **You write the mapping logic.** No conventions. No magic. ## Controlling headers Headers determine how browsers cache and handle files. ### Content-Type Always set Content-Type: ```typescript return new Response(file, { headers: { "Content-Type": "image/png" } }); ``` Without it, browsers might misinterpret the file. ### Cache-Control Control browser and CDN caching: ```typescript // Immutable assets (with content hash in filename) return new Response(file, { headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=31536000, immutable" } }); // HTML (don't cache) return new Response(html, { headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" } }); // Images (cache for 1 hour) return new Response(image, { headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=3600" } }); ``` **Cache-Control values**: - `public` - Can be cached by browsers and CDNs - `private` - Only browser can cache (not CDNs) - `no-cache` - Must revalidate before using cached version - `no-store` - Don't cache at all - `max-age=N` - Cache for N seconds - `immutable` - File will never change (safe to cache forever) ### ETag Support conditional requests: ```typescript route.get("/api.js", { resolve: async (c) => { const file = await Deno.readFile("./dist/api.js"); // Generate ETag (hash of file contents) const hash = await crypto.subtle.digest("SHA-256", file); const etag = btoa(String.fromCharCode(...new Uint8Array(hash))); // Check If-None-Match header const ifNoneMatch = c.request.headers.get("if-none-match"); if (ifNoneMatch === etag) { // File hasn't changed return new Response(null, { status: 304 }); // Not Modified } // File changed or first request return new Response(file, { headers: { "Content-Type": "application/javascript", "ETag": etag, "Cache-Control": "public, max-age=3600" } }); } }) ``` **ETags enable efficient revalidation.** Browser only downloads if ETag changed. ### Content-Encoding For pre-compressed files: ```typescript route.get("/app.js", { resolve: async (c) => { const acceptEncoding = c.request.headers.get("accept-encoding") || ""; // Check if client accepts gzip if (acceptEncoding.includes("gzip")) { try { const compressed = await Deno.readFile("./dist/app.js.gz"); return new Response(compressed, { headers: { "Content-Type": "application/javascript", "Content-Encoding": "gzip", "Cache-Control": "public, max-age=31536000" } }); } catch { // Fall through to uncompressed } } // Serve uncompressed const file = await Deno.readFile("./dist/app.js"); return new Response(file, { headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=31536000" } }); } }) ``` **Pre-compression** is more efficient than runtime compression. ### Security headers For HTML files, add security headers: ```typescript route.get("/", { resolve: async () => { const html = await Deno.readTextFile("./public/index.html"); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache", // Security headers "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'", "Referrer-Policy": "strict-origin-when-cross-origin" } }); } }) ``` **Security headers protect against common attacks.** ### CORS headers For assets used cross-origin: ```typescript route.get("/fonts/:filename", { resolve: async (c) => { const filename = c.raw.params.filename; const file = await Deno.readFile(`./public/fonts/${filename}`); return new Response(file, { headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=31536000", // CORS for web fonts "Access-Control-Allow-Origin": "*" } }); } }) ``` **Web fonts require CORS** to load cross-origin. ### Content-Disposition Force download instead of display: ```typescript route.get("/download/:filename", { resolve: async (c) => { const filename = c.raw.params.filename; const file = await Deno.readFile(`./downloads/${filename}`); return new Response(file, { headers: { "Content-Type": "application/octet-stream", "Content-Disposition": `attachment; filename="${filename}"` } }); } }) ``` **Content-Disposition: attachment** triggers download dialog. ## Complete example: static file server Putting it all together: ```typescript import { route, setup } from "@hectoday/http"; // Helper: validate file path function isValidPath(path: string): boolean { return ( path && !path.includes("..") && !path.startsWith("/") && !path.includes("\0") ); } // Helper: get content type function getContentType(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase(); const types: Record = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "application/javascript; charset=utf-8", json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", ico: "image/x-icon", woff: "font/woff", woff2: "font/woff2", }; return types[ext || ""] || "application/octet-stream"; } // Helper: get cache control function getCacheControl(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase(); // HTML: no cache if (ext === "html") { return "no-cache"; } // Assets with hash in filename: cache forever if (filename.match(/\.[a-f0-9]{8,}\./)) { return "public, max-age=31536000, immutable"; } // Other assets: cache for 1 hour return "public, max-age=3600"; } // Serve index.html at root const indexRoute = route.get("/", { resolve: async () => { const html = await Deno.readTextFile("./public/index.html"); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff" } }); } }); // Serve static assets const staticRoute = route.get("/static/*", { resolve: async (c) => { const url = new URL(c.request.url); const requestPath = url.pathname.slice("/static/".length); // Validate path if (!isValidPath(requestPath)) { return Response.json({ error: "Invalid path" }, { status: 400 }); } const filePath = `./public/${requestPath}`; try { const file = await Deno.readFile(filePath); return new Response(file, { headers: { "Content-Type": getContentType(requestPath), "Cache-Control": getCacheControl(requestPath) } }); } catch (error) { if (error instanceof Deno.errors.NotFound) { return Response.json({ error: "Not found" }, { status: 404 }); } console.error("Error serving file:", error); return Response.json({ error: "Internal error" }, { status: 500 }); } } }); // Setup const app = setup({ handlers: [indexRoute, staticRoute] }); Deno.serve(app.fetch); ``` **This is a complete static file server.** ~100 lines. Full control over everything. --- Next: [Runtime independence](./runtime-independence) - running the same code on Deno, Bun, and Workers. ================================================================================ ## Runtime independence > Running the same code across Deno, Bun, and Cloudflare Workers > Source: /docs/runtime-independence ================================================================================ One codebase. Multiple runtimes. Because Hectoday HTTP is built on Web Standards, your handlers work everywhere. The only thing that changes is how you start the server. ## Running on Deno Deno has first-class TypeScript support and built-in Web Standards. ### Installation ```bash deno add jsr:@hectoday/http ``` ### Basic server ```typescript // main.ts import { route, setup } from "@hectoday/http"; const app = setup({ handlers: [ route.get("/", { resolve: () => new Response("Hello from Deno!") }) ] }); // Deno's native server Deno.serve(app.fetch); ``` Run it: ```bash deno run --allow-net main.ts ``` **That's it.** No build step. No bundler. Just run. ### With options ```typescript Deno.serve({ port: 8000, hostname: "0.0.0.0", onListen: ({ port, hostname }) => { console.log(`Server running at http://${hostname}:${port}`); } }, app.fetch); ``` ### With TLS ```typescript Deno.serve({ port: 443, cert: Deno.readTextFileSync("./cert.pem"), key: Deno.readTextFileSync("./key.pem") }, app.fetch); ``` **Deno handles HTTP/2 automatically** when using TLS. ### Deno-specific features **File system access**: ```typescript route.get("/files/:filename", { resolve: async (c) => { const filename = c.raw.params.filename; // Validate filename... try { // Deno's file API const file = await Deno.open(`./files/${filename}`); return new Response(file.readable, { headers: { "Content-Type": "application/octet-stream" } }); } catch (error) { if (error instanceof Deno.errors.NotFound) { return Response.json({ error: "Not found" }, { status: 404 }); } throw error; } } }) ``` **Environment variables**: ```typescript const app = setup({ handlers: [...], onRequest: ({ request }) => { const dbUrl = Deno.env.get("DATABASE_URL"); const debug = Deno.env.get("DEBUG") === "true"; return { dbUrl, debug }; } }); ``` **Permissions model**: ```bash # Explicit permissions deno run \ --allow-net \ --allow-read=./files \ --allow-env=DATABASE_URL \ main.ts ``` Deno's permission system prevents accidental access to sensitive resources. ## Running on Bun Bun is a fast JavaScript runtime with built-in bundler and package manager. ### Installation ```bash bunx jsr add @hectoday/http ``` Or with npm: ```bash npm install @hectoday/http ``` ### Basic server ```typescript // server.ts import { route, setup } from "@hectoday/http"; const app = setup({ handlers: [ route.get("/", { resolve: () => new Response("Hello from Bun!") }) ] }); // Bun's native server Bun.serve({ fetch: app.fetch, port: 3000 }); console.log("Server running at http://localhost:3000"); ``` Run it: ```bash bun run server.ts ``` **Fast startup.** Bun optimizes for speed. ### With options ```typescript Bun.serve({ fetch: app.fetch, port: 3000, hostname: "0.0.0.0", // Bun-specific options development: process.env.NODE_ENV !== "production", error(error) { console.error("Server error:", error); return new Response("Internal error", { status: 500 }); } }); ``` ### With TLS ```typescript Bun.serve({ fetch: app.fetch, port: 443, tls: { cert: Bun.file("./cert.pem"), key: Bun.file("./key.pem") } }); ``` ### Bun-specific features **Optimized file serving**: ```typescript route.get("/assets/:filename", { resolve: (c) => { const filename = c.raw.params.filename; // Validate filename... // Bun.file() uses zero-copy sendfile() when possible const file = Bun.file(`./public/${filename}`); return new Response(file, { headers: { "Content-Type": "application/octet-stream" } }); } }) ``` **Bun's file API is optimized** for serving static files. **Built-in WebSockets**: ```typescript Bun.serve({ fetch: app.fetch, websocket: { open(ws) { console.log("WebSocket opened"); }, message(ws, message) { ws.send(`Echo: ${message}`); }, close(ws) { console.log("WebSocket closed"); } } }); ``` Bun has first-class WebSocket support (separate from HTTP routes). **Environment variables**: ```typescript const app = setup({ handlers: [...], onRequest: ({ request }) => { const dbUrl = process.env.DATABASE_URL; const debug = process.env.DEBUG === "true"; return { dbUrl, debug }; } }); ``` ## Running on Workers Cloudflare Workers run on the edge, close to users globally. ### Installation Workers use npm packages: ```bash npm install @hectoday/http ``` ### Basic worker ```typescript // src/index.ts import { route, setup } from "@hectoday/http"; const app = setup({ handlers: [ route.get("/", { resolve: () => new Response("Hello from Workers!") }) ] }); // Workers export default with fetch export default { fetch: app.fetch }; ``` ### wrangler.toml ```toml name = "my-worker" main = "src/index.ts" compatibility_date = "2024-01-01" [build] command = "npm install" ``` Deploy: ```bash npx wrangler deploy ``` ### With environment variables Workers use `env` bindings: ```typescript interface Env { DB: D1Database; KV: KVNamespace; R2: R2Bucket; API_KEY: string; } const app = setup({ handlers: [ route.get("/data", { resolve: async (c) => { // Access env through context const env = c.locals.env as Env; const data = await env.DB.prepare( "SELECT * FROM users" ).all(); return Response.json(data.results); } }) ] }); export default { fetch(request: Request, env: Env, ctx: ExecutionContext) { // Attach env to locals via onRequest const appWithEnv = setup({ handlers: app.handlers, onRequest: ({ request }) => ({ env }) }); return appWithEnv.fetch(request); } }; ``` ### Workers-specific features **No file system**: Workers don't have a traditional file system. Use KV or R2: ```typescript route.get("/assets/:key", { resolve: async (c) => { const env = c.locals.env as Env; const key = c.raw.params.key; // Fetch from R2 bucket const object = await env.ASSETS.get(key); if (!object) { return Response.json({ error: "Not found" }, { status: 404 }); } return new Response(object.body, { headers: { "Content-Type": object.httpMetadata?.contentType || "application/octet-stream", "ETag": object.httpEtag } }); } }) ``` **KV for caching**: ```typescript route.get("/cached-data", { resolve: async (c) => { const env = c.locals.env as Env; // Try cache first const cached = await env.KV.get("data", "json"); if (cached) { return Response.json(cached); } // Fetch and cache const data = await fetchExpensiveData(); await env.KV.put("data", JSON.stringify(data), { expirationTtl: 3600 // 1 hour }); return Response.json(data); } }) ``` **Scheduled events** (cron): ```typescript export default { fetch: app.fetch, scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { ctx.waitUntil( // Run background task cleanupExpiredData(env) ); } }; ``` **Edge runtime limitations**: - No native Node.js APIs - Limited execution time (CPU time limits) - No long-running processes - No WebSockets (use Durable Objects instead) ## What changes (and what doesn't) The beauty of Web Standards: **your handler code doesn't change**. ### What stays the same **Your routes**: ```typescript // Works on Deno, Bun, and Workers route.post("/users", { request: { body: z.object({ name: z.string(), email: z.string().email() }) }, guards: [requireAuth], resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const user = await db.users.create(c.input.body); return Response.json(user, { status: 201 }); } }) ``` **This exact code** runs on all three runtimes. **Your guards**: ```typescript // Works everywhere 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 } }; }; ``` **Your validators**: ```typescript // Works everywhere const userSchema = z.object({ name: z.string(), email: z.string().email() }); ``` **Your setup**: ```typescript // Works everywhere (except server initialization) const app = setup({ handlers: [...], validator: zodValidator, onRequest: ({ request }) => ({ requestId: crypto.randomUUID() }), onResponse: ({ context, response }) => addHeaders(response), onError: ({ error, context }) => handleError(error) }); ``` **99% of your code is portable.** Only server initialization changes. ### What changes **Server initialization**: ```typescript // Deno Deno.serve(app.fetch); // Bun Bun.serve({ fetch: app.fetch }); // Workers export default { fetch: app.fetch }; ``` **Three different ways to start the server.** Same `app.fetch` function. **File system access**: ```typescript // Deno const content = await Deno.readTextFile("./file.txt"); // Bun const content = await Bun.file("./file.txt").text(); // Workers // No file system - use KV/R2 const content = await env.KV.get("file"); ``` **Environment variables**: ```typescript // Deno const value = Deno.env.get("KEY"); // Bun/Node const value = process.env.KEY; // Workers const value = env.KEY; // From bindings ``` **External services**: ```typescript // Deno/Bun - direct database access const users = await db.users.getAll(); // Workers - use D1, KV, R2, or external APIs const users = await env.DB.prepare("SELECT * FROM users").all(); ``` ### Abstracting runtime differences Create runtime adapters: ```typescript // lib/runtime.ts export interface RuntimeAdapter { readFile(path: string): Promise; getEnv(key: string): string | undefined; } // Deno adapter export const denoAdapter: RuntimeAdapter = { readFile: (path) => Deno.readTextFile(path), getEnv: (key) => Deno.env.get(key) }; // Bun adapter export const bunAdapter: RuntimeAdapter = { readFile: async (path) => await Bun.file(path).text(), getEnv: (key) => process.env[key] }; // Workers adapter (no file system) export const workersAdapter: RuntimeAdapter = { readFile: async (path) => { throw new Error("File system not available in Workers"); }, getEnv: (key) => { // Access from context return undefined; } }; ``` Use in handlers: ```typescript // Pass adapter through locals const app = setup({ handlers: [...], onRequest: ({ request }) => ({ runtime: denoAdapter // or bunAdapter, or workersAdapter }) }); route.get("/config", { resolve: async (c) => { const runtime = c.locals.runtime as RuntimeAdapter; const config = await runtime.readFile("./config.json"); return new Response(config, { headers: { "Content-Type": "application/json" } }); } }) ``` ## Environment ≠ API The runtime environment changes. The API doesn't. ### The separation ``` Your Handlers (portable) ↓ Web Standards (Request/Response) ↓ Runtime (Deno/Bun/Workers) ``` **Handlers talk to Web Standards, not runtimes.** This means: - Write once, run anywhere - Test in one runtime, deploy to another - Switch runtimes without rewriting code - No vendor lock-in ### Testing across runtimes Your tests work everywhere: ```typescript // test.ts import { assertEquals } from "@std/assert"; import { route, setup } from "@hectoday/http"; Deno.test("GET /health returns 200", async () => { const app = setup({ handlers: [ route.get("/health", { resolve: () => Response.json({ status: "ok" }) }) ] }); const request = new Request("http://localhost/health"); const response = await app.fetch(request); assertEquals(response.status, 200); assertEquals(await response.json(), { status: "ok" }); }); ``` **This test runs on Deno, Bun, or any runtime with a test runner.** ### Development vs production Develop on one runtime, deploy to another: ```typescript // Same code const app = setup({ handlers: [ route.get("/", { resolve: () => new Response("Hello!") }) ] }); // Different initialization if (import.meta.env?.DENO) { // Development: Deno Deno.serve(app.fetch); } else if (typeof Bun !== "undefined") { // Development: Bun Bun.serve({ fetch: app.fetch }); } else { // Production: Workers export default { fetch: app.fetch }; } ``` **Or keep them separate**: ```typescript // handlers.ts - portable export const handlers = [ route.get("/", { resolve: () => new Response("Hello!") }) ]; // deno.ts - Deno-specific import { setup } from "@hectoday/http"; import { handlers } from "./handlers.ts"; const app = setup({ handlers }); Deno.serve(app.fetch); // bun.ts - Bun-specific import { setup } from "@hectoday/http"; import { handlers } from "./handlers.ts"; const app = setup({ handlers }); Bun.serve({ fetch: app.fetch }); // worker.ts - Workers-specific import { setup } from "@hectoday/http"; import { handlers } from "./handlers.ts"; const app = setup({ handlers }); export default { fetch: app.fetch }; ``` **Handlers are pure.** Server setup is runtime-specific. ### The promise of web standards When you build on Web Standards: **Your code survives runtime churn**: - New runtimes emerge → your code still works - Old runtimes die → migrate easily - Runtime A is better for feature X → switch without rewriting **Your skills transfer**: - Learn Fetch API once → use everywhere - Know Request/Response → know all runtimes - Understand Web Standards → understand any platform **Your dependencies simplify**: - No runtime-specific HTTP framework - No adapter layers - No compatibility shims - Just Web Standards ### The portability guarantee ```typescript // This code is a contract const app = setup({ handlers: [ route.get("/", { resolve: () => new Response("Hello World") }) ] }); // As long as the runtime supports: // 1. Request (Web Standard) // 2. Response (Web Standard) // 3. fetch function signature (Web Standard) // This code will work. ``` **Hectoday HTTP makes one promise: if your runtime implements Web Standards, your handlers will work.** That's why it's called "runtime independence." --- Next: [Testing the path](./testing-the-path) - verifying your handlers work correctly. # PART 5: REFERENCE ================================================================================ ## Reference > Complete API reference for Hectoday HTTP > Source: /docs/reference ================================================================================ This is the complete API reference. Use it when you need exact details about types, signatures, and behavior. For concepts and examples, see the earlier chapters. This is just the facts. ## Core types ### Context The context object passed to guards and handlers. ```typescript interface Context< TParams = unknown, TQuery = unknown, TBody = unknown > { request: Request; raw: RawValues; input: InputState; locals: Record; } ``` **Properties**: - `request: Request` - The original Web Standard Request object - `raw: RawValues` - Extracted but unvalidated inputs - `input: InputState` - Validation results (ok or not ok) - `locals: Record` - Request-scoped data from hooks and guards **Type parameters**: - `TParams` - Type of validated params (inferred from schema) - `TQuery` - Type of validated query (inferred from schema) - `TBody` - Type of validated body (inferred from schema) ### RawValues Extracted inputs from the request, **not validated**. ```typescript interface RawValues { params: Record; query: Record; body?: unknown; } ``` **Properties**: - `params` - Path parameters from URL pattern (e.g., `:id`) - `query` - Query parameters from search string - `body` - Parsed body (only if body schema defined), otherwise undefined **Notes**: - All params are `string | undefined` - Query values can be arrays if parameter appears multiple times - Body is parsed as JSON when body schema is defined - Raw values are **not type-safe**, validate them ### InputState Result of validation. Either all inputs are valid, or some failed. ```typescript type InputState = | InputOk | InputErr; ``` ### InputOk When validation passes: ```typescript interface InputOk { ok: true; params: TParams; query: TQuery; body: TBody; } ``` **Properties**: - `ok: true` - Validation passed - `params` - Validated, typed params - `query` - Validated, typed query - `body` - Validated, typed body **Type safety**: TypeScript infers exact types from your schemas. ### InputErr When validation fails: ```typescript interface InputErr { ok: false; failed: ValidationPart[]; issues: ValidationIssue[]; received: { params?: unknown; query?: unknown; body?: unknown; }; errors?: Partial>; } ``` **Properties**: - `ok: false` - Validation failed - `failed` - Which parts failed (`["params"]`, `["query", "body"]`, etc.) - `issues` - Normalized array of all validation issues - `received` - Raw values that failed validation - `errors` - Original error objects from validator (optional) ### ValidationIssue Normalized validation error: ```typescript interface ValidationIssue { part: ValidationPart; path: readonly string[]; message: string; code?: string; } ``` **Properties**: - `part` - Which part failed: `"params"`, `"query"`, or `"body"` - `path` - Path to the failing field (e.g., `["email"]` or `["user", "name"]`) - `message` - Human-readable error message - `code` - Optional error code from validator ### ValidationPart ```typescript type ValidationPart = "params" | "query" | "body"; ``` Which part of the request is being validated. ### Handler A route descriptor returned by `route.*()` functions: ```typescript interface Handler { method: string | string[]; path: string; handler: HandlerFn; guards?: GuardFn[]; request?: RequestSchemas; } ``` **Properties**: - `method` - HTTP method(s): `"GET"`, `"POST"`, or `["GET", "POST"]` - `path` - URL pattern with optional parameters: `"/users/:id"` - `handler` - The function that returns a Response - `guards` - Optional guards that run before handler - `request` - Optional validation schemas **Notes**: - Created by `route.get()`, `route.post()`, etc. - You rarely construct this manually - Passed to `setup()` in the `handlers` array ### HandlerFn The function that handles the request: ```typescript type HandlerFn = ( c: Context ) => Response | Promise; ``` **Parameters**: - `c: Context` - The request context **Returns**: - `Response | Promise` - Must return a Web Standard Response **Notes**: - Must always return a Response - Can be async - Type parameters inferred from schemas ### RouteParams Type helper to extract param types from a path pattern: ```typescript type RouteParams = /* implementation */ ``` **Usage**: ```typescript type Params = RouteParams<"/users/:id">; // { id: string } type Params2 = RouteParams<"/orgs/:orgId/repos/:repoId">; // { orgId: string; repoId: string } ``` **Notes**: - Automatically inferred by TypeScript - Used internally for type safety - You rarely use this explicitly ## Route functions ### route.get() ```typescript function get( path: TPath, config: RouteConfig ): Handler ``` Create a GET route. **Parameters**: - `path` - URL pattern (e.g., `"/users/:id"`) - `config` - Route configuration **Returns**: Handler descriptor **Example**: ```typescript route.get("/users/:id", { resolve: (c) => Response.json({ id: c.raw.params.id }) }) ``` ### route.post() ```typescript function post( path: TPath, config: RouteConfig ): Handler ``` Create a POST route. ### route.put() ```typescript function put( path: TPath, config: RouteConfig ): Handler ``` Create a PUT route. ### route.patch() ```typescript function patch( path: TPath, config: RouteConfig ): Handler ``` Create a PATCH route. ### route.delete() ```typescript function delete( path: TPath, config: RouteConfig ): Handler ``` Create a DELETE route. ### route.head() ```typescript function head( path: TPath, config: RouteConfig ): Handler ``` Create a HEAD route. ### route.options() ```typescript function options( path: TPath, config: RouteConfig ): Handler ``` Create an OPTIONS route. ### route.all() ```typescript function all( path: TPath, config: RouteConfig ): Handler ``` Create a route that matches **all** HTTP methods. ### route.on() ```typescript function on( method: string, path: TPath, config: RouteConfig ): Handler ``` Create a route for a custom HTTP method. **Parameters**: - `method` - Any HTTP method string (e.g., `"PROPFIND"`) - `path` - URL pattern - `config` - Route configuration **Example**: ```typescript route.on("PROPFIND", "/webdav", { resolve: () => new Response("WebDAV response") }) ``` ### RouteConfig ```typescript interface RouteConfig< TParamsSchema = unknown, TQuerySchema = unknown, TBodySchema = unknown > { request?: RequestSchemas; guards?: GuardFn[]; resolve: HandlerFn; } ``` **Properties**: - `request` - Optional validation schemas - `guards` - Optional guards - `resolve` - Handler function (required) ### RequestSchemas ```typescript interface RequestSchemas< TParamsSchema = unknown, TQuerySchema = unknown, TBodySchema = unknown > { params?: TParamsSchema; query?: TQuerySchema; body?: TBodySchema; } ``` **Properties**: - `params` - Schema for path parameters - `query` - Schema for query string - `body` - Schema for request body **Example**: ```typescript request: { params: z.object({ id: z.string().uuid() }), query: z.object({ include: z.string().optional() }), body: z.object({ name: z.string() }) } ``` ## Setup and Configuration ### setup() ```typescript function setup(config: Config | Handler[]): { fetch: (req: Request) => Promise; } ``` Bootstrap the Hectoday HTTP application. **Parameters**: - `config: Config | Handler[]` - Configuration object or array of handlers **Returns**: Object with `fetch` method **Example**: ```typescript const app = setup({ handlers: [...], validator: zodValidator, onRequest: ({ request }) => ({ requestId: crypto.randomUUID() }), onResponse: ({ context, response }) => response, onError: ({ error, context }) => Response.json({ error: "Internal error" }, { status: 500 }) }); ``` ### Config ```typescript interface Config { handlers: Handler[]; validator?: Validator; onRequest?: OnRequestHandler; onResponse?: OnResponseHandler; onError?: OnErrorHandler; } ``` **Properties**: - `handlers` - Array of route handlers (required) - `validator` - Validator adapter (required if any route uses schemas) - `onRequest` - Hook that runs before routing - `onResponse` - Hook that runs after handler - `onError` - Hook that handles unexpected errors ### OnRequestHandler ```typescript type OnRequestHandler = ( info: { request: Request } ) => void | Record | Promise>; ``` Runs **before routing**, receives the raw Request. **Parameters**: - `info.request: Request` - The incoming request **Returns**: - `void` - No locals to add - `Record` - Locals to merge into context - `Promise` - Async version of above **Parameter Styles**: You can use either style: ```typescript // Destructured (concise) onRequest: ({ request }) => { return { requestId: crypto.randomUUID() }; } // Named parameter (explicit) onRequest: (info) => { const { request } = info; return { requestId: crypto.randomUUID() }; } ``` **Notes**: - Cannot deny requests - Cannot return Response - Only adds to `c.locals` **Example**: ```typescript onRequest: ({ request }) => { return { requestId: crypto.randomUUID(), startTime: Date.now() }; } ``` ### OnResponseHandler ```typescript type OnResponseHandler = ( info: { context: Context; response: Response } ) => Response | Promise; ``` Runs **after handler**, can modify the response. **Parameters**: - `info.context: Context` - The request context - `info.response: Response` - The response from handler **Returns**: - `Response` - Modified or original response **Parameter Styles**: You can use either style: ```typescript // Destructured (concise) - use only what you need onResponse: ({ response }) => { const headers = new Headers(response.headers); headers.set("x-powered-by", "hectoday"); return new Response(response.body, { status: response.status, headers }); } // Named parameter (explicit) onResponse: (info) => { const { context, response } = info; const headers = new Headers(response.headers); headers.set("x-request-id", context.locals.requestId); return new Response(response.body, { status: response.status, headers }); } ``` **Example**: ```typescript onResponse: ({ context, response }) => { 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 }); } ``` ### OnErrorHandler ```typescript type OnErrorHandler = ( info: { error: unknown; context: Context } ) => Response | Promise; ``` Handles **unexpected errors** that escape handlers. **Parameters**: - `info.error: unknown` - The thrown error - `info.context: Context` - Minimal context (might not have full data) **Returns**: - `Response` - Error response to send to client **Parameter Styles**: You can use either style: ```typescript // Destructured (concise) onError: ({ error, context }) => { console.error("Error:", error); return Response.json({ error: "Internal Error" }, { status: 500 }); } // Named parameter (explicit) onError: (info) => { const { error, context } = info; console.error(`Error for ${context.request.url}:`, error); return Response.json({ error: "Internal Error" }, { status: 500 }); } // Only need one property? Just destructure that onError: ({ error }) => { console.error(error); return Response.json({ error: "Internal Error" }, { status: 500 }); } ``` **Notes**: - Only catches **unexpected** errors (bugs, crashes) - Expected errors should be returned explicitly in handlers - Don't expose error details to clients in production **Example**: ```typescript onError: ({ error, context }) => { console.error("Unexpected error:", { error, requestId: context.locals.requestId, path: context.request.url }); return Response.json( { error: "Internal server error" }, { status: 500 } ); } ``` ## Guard API ### GuardFn ```typescript type GuardFn = (c: Context) => GuardResult | Promise; ``` A function that makes an allow/deny decision. **Parameters**: - `c: Context` - Request context **Returns**: - `GuardResult` - Allow or deny **Notes**: - Can be async - Must return `GuardResult` - Never throws (to deny, return `{ deny: Response }`) ### GuardResult ```typescript type GuardResult = | { allow: true; locals?: Record } | { deny: Response }; ``` The result of a guard. ### Allow result ```typescript { allow: true; locals?: Record } ``` **Properties**: - `allow: true` - Request continues - `locals` - Optional data to add to `c.locals` **Example**: ```typescript return { allow: true, locals: { userId: "123" } }; ``` ### Deny result ```typescript { deny: Response } ``` **Properties**: - `deny: Response` - The response to send (request ends) **Example**: ```typescript return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) }; ``` ### Guard example ```typescript 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 } }; }; ``` ## Group API ### group() ```typescript function group(options: GroupOptions): Handler[] ``` Apply guards to multiple handlers. **Parameters**: - `options: GroupOptions` - Group configuration **Returns**: Array of handlers with guards prepended **Example**: ```typescript const adminRoutes = group({ guards: [requireAuth, requireAdmin], handlers: [ route.get("/admin/users", { resolve: ... }), route.delete("/admin/users/:id", { resolve: ... }) ] }); ``` ### GroupOptions ```typescript interface GroupOptions { guards: GuardFn[]; handlers: (Handler | Handler[])[]; } ``` **Properties**: - `guards` - Guards to apply to all handlers - `handlers` - Handlers (or nested groups) to apply guards to **Notes**: - Guards are **prepended** to each handler's guards - Nested groups accumulate guards - Operates at **build time** (no runtime overhead) ## Validator API ### Validator ```typescript interface Validator { validate( schema: S, input: unknown, part: ValidationPart ): ValidateResult, InferSchemaError>; } ``` Adapter interface for validation libraries. **Type parameters**: - `TSchema` - The schema type from your validation library **Methods**: - `validate()` - Validate input against schema ### validate() ```typescript validate( schema: S, input: unknown, part: ValidationPart ): ValidateResult, InferSchemaError> ``` **Parameters**: - `schema` - The schema to validate against - `input` - The data to validate (unknown type) - `part` - Which part is being validated (`"params"`, `"query"`, `"body"`) **Returns**: `ValidateResult` (success or failure) ### ValidateResult ```typescript type ValidateResult = | ValidateOk | ValidateErr; ``` ### ValidateOk ```typescript interface ValidateOk { ok: true; value: T; } ``` **Properties**: - `ok: true` - Validation succeeded - `value: T` - The validated, typed value ### ValidateErr ```typescript interface ValidateErr { ok: false; issues: ValidationIssue[]; error?: TErr; } ``` **Properties**: - `ok: false` - Validation failed - `issues` - Normalized array of issues - `error` - Optional original error from validator ### SchemaLike ```typescript interface SchemaLike { safeParse(input: unknown): SafeParseResult; } ``` Minimal interface that validation schemas must implement. **Methods**: - `safeParse()` - Parse input, return success or failure ### SafeParseResult ```typescript type SafeParseResult = | SafeParseSuccess | SafeParseFailure; ``` ### SafeParseSuccess ```typescript interface SafeParseSuccess { success: true; data: T; } ``` ### SafeParseFailure ```typescript interface SafeParseFailure { success: false; error: E; } ``` ### Validator example (Zod) ```typescript import { z } from "zod"; import type { Validator, ValidationIssue } from "@hectoday/http"; export const zodValidator: Validator = { validate(schema, input, part) { const result = schema.safeParse(input); if (result.success) { return { ok: true, value: result.data }; } const issues: ValidationIssue[] = result.error.issues.map(issue => ({ part, path: issue.path.map(String), message: issue.message, code: issue.code })); return { ok: false, issues, error: result.error }; } }; ``` ## Type inference ### InferSchema ```typescript type InferSchema = T extends SchemaLike ? TOut : never; ``` Extract the output type from a schema. **Example**: ```typescript const schema = z.object({ name: z.string() }); type Output = InferSchema; // { name: string } ``` ### InferSchemaError ```typescript type InferSchemaError = T extends SchemaLike ? TErr : never; ``` Extract the error type from a schema. ### InferInput ```typescript type InferInput = T extends SchemaLike ? TOut : T extends { safeParse: any } ? any : never; ``` Infer input type from schema (for type-safe handlers). ## Helper recipes Common helper patterns available as copy-paste recipes in the documentation. ### Available helpers - **[Zod validator](./helpers/zod-validator)** - Validator adapter for Zod schemas - **[maxBodyBytes](./helpers/max-body-bytes)** - Limit request body size (guard) - **[CORS](./helpers/cors)** - Add CORS headers to responses - **[Request ID](./helpers/request-id)** - Generate and track request IDs - **[Rate limiting](./helpers/rate-limit)** - Limit requests per client ### Usage pattern Helpers are **copy-paste recipes**, not dependencies: 1. Visit the helper documentation page 2. Copy the code to your project (e.g., `helpers/maxBodyBytes.ts`) 3. Import from your project 4. Modify as needed **Example:** ```typescript // 1. Copy code from docs to helpers/maxBodyBytes.ts // 2. Import from YOUR project import { maxBodyBytes, SIZES } from "./helpers/maxBodyBytes.ts"; route.post("/upload", { guards: [maxBodyBytes(10 * SIZES.MB)], resolve: async (c) => { const data = await c.request.arrayBuffer(); return Response.json({ size: data.byteLength }); } }); ``` ### Why copy-paste? - **You own the code** - No external dependencies - **No version conflicts** - No need to track updates - **Modify freely** - Change without forking - **Copy only what you need** - No bloat See [Composition Over Configuration](./composition-over-configuration#helpers-as-copy-paste-recipes) for more details. ## Constants ### HTTP status codes No built-in constants, use numbers directly: ```typescript return Response.json({ error: "Not found" }, { status: 404 }); ``` Common status codes: | Code | Meaning | |------|---------| | 200 | OK | | 201 | Created | | 204 | No Content | | 400 | Bad Request | | 401 | Unauthorized | | 403 | Forbidden | | 404 | Not Found | | 409 | Conflict | | 422 | Unprocessable Entity | | 429 | Too Many Requests | | 500 | Internal Server Error | | 503 | Service Unavailable | ### HTTP methods No built-in constants, use strings: ```typescript route.on("PROPFIND", "/webdav", { resolve: ... }) ``` ## Error handling ### Framework errors Hectoday HTTP throws errors for: **No validator provided when schemas exist**: ```typescript // Throws: "Validator is required when route defines request schemas" const app = setup({ handlers: [ route.post("/users", { request: { body: schema }, // Schema defined resolve: (c) => ... }) ] // Missing validator! }); ``` **Solution**: Provide a validator: ```typescript const app = setup({ validator: zodValidator, // ✓ Now provided handlers: [...] }); ``` ### 404 handling **Framework returns 404** when no route matches: ```typescript // No route for /unknown const app = setup({ handlers: [...] }); const response = await app.fetch(new Request("http://localhost/unknown")); // response.status === 404 // response.body === "Not Found" ``` **Custom 404**: Add a catch-all route: ```typescript route.all("/*", { resolve: () => Response.json({ error: "Not found" }, { status: 404 }) }) ``` ## Version compatibility **Minimum runtime requirements**: - Deno 1.30+ - Bun 1.0+ - Node.js 18+ (with fetch support) - Cloudflare Workers (any version with Request/Response) **Web Standard APIs required**: - `Request` - `Response` - `Headers` - `URLPattern` (for route matching) - `crypto.randomUUID()` (for request IDs in helpers) --- Next: [Philosophy (revisited)](./philosophy-revisited) - Why Hectoday HTTP makes these design choices. ================================================================================ ## Philosophy (revisited) > Why Hectoday HTTP makes these design choices > Source: /docs/philosophy-revisited ================================================================================ You've read the concepts. You've seen the examples. You've studied the reference. Now let's return to the question: **Why is Hectoday HTTP designed this way?** ## Why there is no magic Magic in frameworks comes in many forms: - Middleware that auto-returns responses - Decorators that auto-generate routes - Exceptions that auto-map to status codes - Conventions that auto-wire dependencies **Magic is convenient.** It saves typing. It feels productive. **But magic has a cost.** ### Magic hides control flow ```typescript // In a framework with magic @Route("/users/:id") @RequireAuth() // Does this return 401? When? @ValidateParams(UserIdSchema) // Does this return 400? When? async getUser(id: string) { const user = await db.users.get(id); return user; // What if user is null? What happens? } ``` Reading this code, you can't answer: - When does the request end? - What status codes can this return? - What responses does the client see? - Where do errors go? **To understand the code, you need to understand the framework's magic.** ### Explicitness makes control flow visible ```typescript // In Hectoday HTTP route.get("/users/:id", { guards: [requireAuth], // Explicit: returns 401 if no auth request: { params: z.object({ id: z.string().uuid() }) // Explicit: validation }, resolve: async (c) => { // Explicit: check validation if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const user = await db.users.get(c.input.params.id); // Explicit: check if user exists if (!user) { return Response.json({ error: "User not found" }, { status: 404 }); } // Explicit: success response return Response.json(user); } }) ``` Reading this code, you **know**: - Request ends if `requireAuth` denies (401) - Request ends if validation fails (400) - Request ends if user not found (404) - Request ends with user data (200) **Every decision is visible. Every branch is explicit. No framework magic required.** ### The trade-off **Magic framework**: - ✓ Less code to write - ✓ Faster initial development - ✗ Hidden control flow - ✗ Framework knowledge required - ✗ Debugging is harder **Explicit framework**: - ✗ More code to write - ✗ Slower initial development - ✓ Visible control flow - ✓ Code is self-documenting - ✓ Debugging is straightforward **Hectoday HTTP chooses visibility over convenience.** Not because convenience is bad. But because **in production systems that last years, maintained by multiple people, visibility is more valuable than convenience.** ### Magic breaks down at scale Small project (1 developer, 3 months): ```typescript // Magic works fine @Route("/api/data") @Auth() getData() { return this.dataService.get(); } ``` **Magic is great here.** One person understands it. Code is simple. Large project (10 developers, 3 years): ```typescript // What does this actually do? @Route("/api/v2/organizations/:orgId/projects/:projectId/reports") @Auth() @RequireSubscription("premium") @RateLimit(100, "1h") @CacheFor("5m") @AuditLog() @Validated(ReportSchema) async getReport(orgId: string, projectId: string, params: ReportParams) { // 300 lines of business logic } ``` **Magic breaks down.** Six different decorators. Each with its own rules. Each potentially returning different responses. The actual control flow is completely hidden. New developer questions: - "Which decorator runs first?" - "What happens if Auth fails?" - "Does RateLimit run if Auth fails?" - "What status codes can this return?" - "Where do I add logging?" **Answers require reading framework docs, not the code.** Explicit version: ```typescript route.get("/api/v2/organizations/:orgId/projects/:projectId/reports", { guards: [ requireAuth, requireSubscription("premium"), rateLimit(100, 60 * 60 * 1000), auditLog("report_access") ], request: { params: z.object({ orgId: z.string().uuid(), projectId: z.string().uuid() }), query: reportSchema }, resolve: async (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // 300 lines of business logic // Every return statement is visible // Every status code is explicit } }) ``` **All behavior is visible:** - Guards run in order: auth → subscription → rate limit → audit - Each guard can deny (explicit Response) - Validation can fail (explicit 400) - Handler logic is explicit **New developer can read this code and understand it without reading framework docs.** ### Magic optimizes for writing, not reading You write code once. You read it hundreds of times. **Magic optimizes for writing:** - Fewer lines typed - Less boilerplate - "DRY" (Don't Repeat Yourself) **Explicitness optimizes for reading:** - Control flow is visible - Decisions are local - No hidden behavior **Code is read 10x more than it's written.** Hectoday HTTP optimizes for reading. ### The real cost of magic The cost of magic isn't lines of code. It's **cognitive load**. **With magic:** - "What does this decorator do?" - "Where is this coming from?" - "Why did this return 401?" - "How do I debug this?" Every question requires **context switching** to framework docs or source code. **With explicitness:** - Everything is in the code you're reading - Questions are answered locally - Debugging is tracing execution **Less context switching = faster understanding = faster development.** ## Why explicitness scales Explicit code has properties that become more valuable as projects grow. ### Property 1: code is self-documenting ```typescript // This code documents itself route.delete("/admin/users/:id", { guards: [ requireAuth, // 1. Must be authenticated requireAdmin, // 2. Must be admin requireEmailVerified, // 3. Email must be verified requireNotSelf // 4. Can't delete self ], resolve: async (c) => { const id = c.raw.params.id; // Check if user exists const user = await db.users.get(id); if (!user) { return Response.json({ error: "User not found" }, { status: 404 }); } // Check if user has active sessions const sessions = await db.sessions.getByUserId(id); if (sessions.length > 0) { return Response.json( { error: "Cannot delete user with active sessions" }, { status: 409 } ); } // Soft delete await db.users.softDelete(id); return new Response(null, { status: 204 }); } }) ``` **Reading this tells you:** - Who can call this endpoint (admins with verified emails, not self) - What can go wrong (not found, active sessions) - What happens on success (soft delete, 204) **No external docs needed.** The code is the documentation. ### Property 2: changes are localized To change behavior, you change the code directly: **Want to add a new check?** ```typescript // Add a guard guards: [ requireAuth, requireAdmin, requireEmailVerified, requireNotSelf, requireNoActiveOrders // ← New guard ], ``` **Want to change an error message?** ```typescript // Change the return statement if (!user) { return Response.json( { error: "User not found", hint: "Check that the user ID is correct" // ← Changed }, { status: 404 } ); } ``` **Want to add logging?** ```typescript resolve: async (c) => { console.log("Deleting user:", c.raw.params.id); // ← Added const id = c.raw.params.id; // ... } ``` **All changes are local.** No framework config. No global middleware. Just edit the handler. ### Property 3: refactoring is safe When code is explicit, refactoring is straightforward: **Extract a helper:** ```typescript // Before if (!user) { return Response.json({ error: "User not found" }, { status: 404 }); } // After if (!user) { return notFound("User not found"); } function notFound(message: string) { return Response.json({ error: message }, { status: 404 }); } ``` **Extract a guard:** ```typescript // Before resolve: async (c) => { const sessions = await db.sessions.getByUserId(id); if (sessions.length > 0) { return Response.json({ error: "Active sessions" }, { status: 409 }); } // ... } // After guards: [ requireAuth, requireAdmin, requireNoActiveSessions // ← Extracted ], resolve: async (c) => { // Sessions already checked by guard // ... } ``` **TypeScript guides refactoring.** If it compiles, it probably works. ### Property 4: testing is straightforward Explicit code is easy to test (as we saw in Chapter 13): ```typescript Deno.test("DELETE /users/:id requires all permissions", async () => { const app = setup({ handlers: [deleteUserRoute] }); // Test 1: No auth → 401 const res1 = await app.fetch( new Request("http://localhost/admin/users/123", { method: "DELETE" }) ); assertEquals(res1.status, 401); // Test 2: Not admin → 403 const res2 = await app.fetch( new Request("http://localhost/admin/users/123", { method: "DELETE", headers: { "Authorization": "Bearer user-token" } }) ); assertEquals(res2.status, 403); // Test 3: Delete self → 400 const res3 = await app.fetch( new Request("http://localhost/admin/users/admin-123", { method: "DELETE", headers: { "Authorization": "Bearer admin-token" } }) ); assertEquals(res3.status, 400); // Test 4: Success → 204 const res4 = await app.fetch( new Request("http://localhost/admin/users/other-user", { method: "DELETE", headers: { "Authorization": "Bearer admin-token" } }) ); assertEquals(res4.status, 204); }); ``` **Every branch is testable.** Just create requests and check responses. ### Property 5: onboarding is faster New team member on **magic framework**: 1. Read framework docs 2. Understand decorators 3. Learn middleware order 4. Understand DI container 5. Learn framework conventions 6. Finally read application code **2 weeks to productivity.** New team member on **Hectoday HTTP**: 1. Read application code 2. See patterns (guards, handlers, responses) 3. Copy existing routes 4. Make changes **2 days to productivity.** **Because the code is self-documenting, learning happens by reading code.** ### Property 6: debugging is tracing execution With magic, debugging requires understanding framework internals. With explicitness, debugging is tracing execution: ```typescript route.post("/users", { guards: [requireAuth, requireAdmin], // Add console.log here resolve: async (c) => { console.log("Handler running"); // Or here if (!c.input.ok) { console.log("Validation failed:", c.input.issues); // Or here return Response.json({ error: c.input.issues }, { status: 400 }); } console.log("Creating user:", c.input.body); // Or here const user = await db.users.create(c.input.body); console.log("User created:", user.id); // Or here return Response.json(user, { status: 201 }); } }) ``` **Every step is visible. Add logging anywhere. Trace the exact path.** ## When hectoday HTTP Is the wrong tool Honesty time: **Hectoday HTTP is not for everyone.** Here's when you should use something else. ### When prototyping If you're: - Building a proof of concept - Validating an idea quickly - Experimenting with different approaches - Throwing away code in a week **Use something with more magic.** Explicitness slows you down when you're exploring. **Good alternatives**: Express, Hono, Elysia (for speed) ### When building trivial APIs If your API is: - 3-5 simple CRUD endpoints - No complex validation - No authorization logic - Will never grow beyond this **Use something simpler.** Hectoday HTTP's structure is overkill. **Good alternatives**: Deno's `Deno.serve()` directly, or any lightweight router ### When you want batteries included If you want: - Built-in ORM - Built-in auth system - Built-in session management - Built-in admin panel - Full-stack framework **Use a full-stack framework.** Hectoday HTTP is just HTTP handling. **Good alternatives**: Remix, Next.js, Fresh (for full-stack) ### When your team prefers magic If your team: - Loves decorators - Prefers "convention over configuration" - Wants minimal boilerplate - Doesn't mind framework-specific patterns **Use what your team prefers.** Team productivity matters more than framework choice. **Good alternatives**: NestJS, Fastify with decorators ### When you need maximum performance If you need: - Absolute lowest latency (every microsecond counts) - Maximum throughput (millions of requests/second) - Metal-close performance **Use something more optimized.** Hectoday HTTP prioritizes clarity over micro-optimizations. **Good alternatives**: Rust/Axum, Go/Gin, Bun with zero-abstraction handlers ### When you're building microservices at scale If you're: - Building 100+ services - Need centralized policy enforcement - Want consistent API gateway patterns - Need service mesh integration **Use a framework designed for microservices.** Hectoday HTTP is for individual services, not orchestration. **Good alternatives**: Use Hectoday HTTP for individual services, but add Istio/Envoy/Kong for orchestration ## When hectoday HTTP Is the Right Tool Hectoday HTTP shines when: ### Building APIs that last Your API will: - Live for years - Be maintained by multiple people - Grow beyond initial scope - Need to be understood by new team members **Explicitness pays off over time.** ### Working in Teams You have: - Multiple developers - Different experience levels - Code review processes - Need for predictable patterns **Self-documenting code reduces communication overhead.** ### Valuing maintainability You prioritize: - Code readability over brevity - Debugging ease over development speed - Long-term understanding over short-term convenience **Explicit code is maintainable code.** ### Need runtime independence You want: - To deploy on multiple runtimes - To switch runtimes without rewriting - To avoid vendor lock-in **Web Standards enable portability.** ### Want security visibility You need: - To audit security controls - To see exactly what checks run - To verify authorization logic - To pass security reviews **Explicit guards make security auditable.** ### Building production APIs Your API needs: - Reliable behavior - Clear error handling - Traceable request flow - Production-ready patterns **Explicit control flow is production-ready.** ## The core trade-off Every framework makes a trade-off: **Magic frameworks**: Fast to write, slow to understand **Explicit frameworks**: Slow to write, fast to understand **The question is: which matters more for your project?** If you're building something that: - Will be thrown away soon - Only you will maintain - Is simple and won't grow **Choose magic.** You'll ship faster. If you're building something that: - Will last years - Multiple people will maintain - Will grow in complexity **Choose explicitness.** You'll maintain faster. ## The philosophy in one sentence **Hectoday HTTP describes what happened. You decide what it means.** The framework never makes HTTP decisions. It computes facts about requests. Guards make allow/deny decisions. Handlers commit responses. **Everything else is your job.** And when debugging at 2am, when code breaks in production, when a new developer joins the team, when a security audit happens, when requirements change... **You'll be glad the code is explicit.** --- ## Closing thoughts If you've read this far, you understand Hectoday HTTP's philosophy. **You might not agree with it.** That's okay. Different projects need different tools. Different teams have different values. But if you value: - Explicit control flow - Self-documenting code - Visible security - Runtime independence - Long-term maintainability **Try Hectoday HTTP.** Write a route. See how it feels. Notice that you can read it six months later and understand exactly what it does. **That's the point.** --- ## Additional resources - [Installation guide](./installation) - [GitHub repository](https://github.com/hectoday/http) - [Example projects](https://github.com/hectoday/http/tree/main/example) - [API reference](./reference) **Build something. See if explicitness works for you.** If it does, welcome. If it doesn't, that's fine too. Use what works. **The best framework is the one that helps your team ship quality software.** For us, that's Hectoday HTTP. # HELPERS (Copy-paste recipes) ================================================================================ ## maxBodyBytes: limit request body size > Guard helper to limit request body size > Source: /docs/helpers/max-body-bytes ================================================================================ A guard helper that limits request body size, protecting against large payloads. ## The code ```typescript const SIZES = { KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024, }; function maxBodyBytes(max: number): GuardFn { return (c) => { const contentLength = c.request.headers.get("content-length"); // If no Content-Length header, allow (body will be read up to limit) if (!contentLength) { return { allow: true }; } const size = parseInt(contentLength, 10); if (size > max) { return { deny: Response.json( { error: "Request body too large", maxBytes: max, receivedBytes: size, }, { status: 413 } // 413 Payload Too Large ), }; } return { allow: true }; }; } ``` ## Usage ```typescript import { route, type GuardFn } from "@hectoday/http"; // Copy the helper code above here // Limit to 1MB route.post("/upload", { guards: [maxBodyBytes(1 * SIZES.MB)], resolve: async (c) => { const data = await c.request.json(); return Response.json({ received: data }); }, }); // Limit to 10MB for file uploads route.post("/files", { guards: [maxBodyBytes(10 * SIZES.MB)], resolve: async (c) => { const formData = await c.request.formData(); return Response.json({ success: true }); }, }); // Limit to 100KB for small payloads route.post("/webhook", { guards: [maxBodyBytes(100 * SIZES.KB)], resolve: async (c) => { const data = await c.request.json(); return Response.json({ processed: true }); }, }); ``` ## Customization ### Custom error response ```typescript function maxBodyBytes(max: number, errorFn?: (size: number, max: number) => Response): GuardFn { return (c) => { const contentLength = c.request.headers.get("content-length"); if (!contentLength) return { allow: true }; const size = parseInt(contentLength, 10); if (size > max) { const errorResponse = errorFn ? errorFn(size, max) : Response.json({ error: "Payload too large" }, { status: 413 }); return { deny: errorResponse }; } return { allow: true }; }; } // Usage with custom error route.post("/api", { guards: [ maxBodyBytes(1 * SIZES.MB, (size, max) => Response.json( { message: "File too big!", yourSize: `${(size / SIZES.MB).toFixed(2)}MB`, maxSize: `${(max / SIZES.MB).toFixed(2)}MB`, }, { status: 413 } ) ), ], resolve: async (c) => { const data = await c.request.json(); return Response.json(data); }, }); ``` ## How it works 1. **Checks `Content-Length` header** - Most clients send this 2. **Compares to max** - Rejects if over limit 3. **Returns 413** - Standard "Payload Too Large" status 4. **Allows if no header** - Runtime will enforce limits when reading body ## Notes - This checks the **declared size**, not actual bytes read - If client omits `Content-Length`, the check passes - Runtime limits (Deno/Bun/Workers) still apply when reading body - Use this for early rejection before parsing large payloads - Combine with request validation for body structure checks ## Why not built-in? This is a policy decision (how big is too big?), not a framework primitive. Different routes have different limits: - Webhooks: 10KB-100KB - JSON APIs: 1MB-10MB - File uploads: 10MB-100MB+ Copy this helper and adjust `SIZES` or `max` values for your needs. ================================================================================ ## Zod validator: schema validation adapter > Validator adapter for using Zod schemas with Hectoday HTTP > Source: /docs/helpers/zod-validator ================================================================================ A validator adapter that integrates Zod schemas with Hectoday HTTP's validation system. ## The code ```typescript import type { InferSchema, InferSchemaError, ValidateResult, ValidationIssue, ValidationPart, Validator, } from "@hectoday/http"; import type { ZodType } from "zod"; export const zodValidator: Validator = { validate( schema: S, input: unknown, part: ValidationPart, ): ValidateResult, InferSchemaError> { const result = schema.safeParse(input); if (result.success) { return { ok: true, value: result.data as InferSchema }; } const issues: ValidationIssue[] = result.error.issues.map((issue) => ({ part, path: issue.path.map(String), message: issue.message, code: issue.code, })); return { ok: false, issues, error: result.error as InferSchemaError, }; }, }; ``` ## Installation First, install Zod: ```bash # Deno deno add npm:zod # Bun bun add zod # npm npm install zod ``` ## Usage ### Basic setup ```typescript import { setup, route } from "@hectoday/http"; import { z } from "zod"; // Copy the zodValidator code above here // Or save it to helpers/zodValidator.ts const app = setup({ validator: zodValidator, handlers: [ route.post("/users", { request: { body: z.object({ name: z.string(), email: z.string().email(), }), }, resolve: (c) => { if (!c.input.ok) { return Response.json( { error: "Invalid input", issues: c.input.issues }, { status: 400 } ); } // c.input.body is typed as { name: string; email: string } const { name, email } = c.input.body; return Response.json({ id: 1, name, email }, { status: 201 }); }, }), ], }); ``` ### With params, query, and body ```typescript route.post("/orgs/:orgId/users", { request: { params: z.object({ orgId: z.string().uuid(), }), query: z.object({ notify: z.enum(["email", "sms", "none"]).default("email"), }), body: z.object({ name: z.string().min(1).max(100), email: z.string().email(), role: z.enum(["admin", "member", "viewer"]), }), }, resolve: (c) => { if (!c.input.ok) { return Response.json( { error: "Validation failed", issues: c.input.issues }, { status: 400 } ); } // All fully typed! const orgId = c.input.params.orgId; // string (UUID) const notify = c.input.query.notify; // "email" | "sms" | "none" const { name, email, role } = c.input.body; return Response.json({ orgId, notify, user: { name, email, role } }); }, }); ``` ## Advanced Zod features ### Transformations ```typescript route.get("/users", { request: { query: z.object({ page: z.string().transform(Number).pipe(z.number().int().positive()), limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)), }), }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // c.input.query.page is number (not string!) // c.input.query.limit is number (not string!) const { page, limit } = c.input.query; return Response.json({ users: [], pagination: { page, limit, total: 0 }, }); }, }); ``` ### Refinements ```typescript route.post("/register", { request: { body: z.object({ email: z.string().email(), password: z.string().min(8), confirmPassword: z.string(), }).refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], }), }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const { email, password } = c.input.body; return Response.json({ success: true }); }, }); ``` ### Optional fields ```typescript route.patch("/users/:id", { request: { body: z.object({ name: z.string().optional(), email: z.string().email().optional(), bio: z.string().max(500).optional(), }), }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // c.input.body: { name?: string; email?: string; bio?: string } const updates = c.input.body; return Response.json({ updated: updates }); }, }); ``` ### Default values ```typescript route.get("/search", { request: { query: z.object({ q: z.string(), sort: z.enum(["relevance", "date", "popularity"]).default("relevance"), order: z.enum(["asc", "desc"]).default("desc"), limit: z.string().transform(Number).pipe(z.number().default(20)), }), }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const { q, sort, order, limit } = c.input.query; // sort defaults to "relevance" if not provided // order defaults to "desc" if not provided // limit defaults to 20 if not provided return Response.json({ query: q, sort, order, limit, results: [] }); }, }); ``` ## Reusable schemas Define schemas once, reuse everywhere: ```typescript // schemas/user.ts import { z } from "zod"; export const userIdSchema = z.string().uuid(); export const createUserSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email(), password: z.string().min(8), }); export const updateUserSchema = createUserSchema.partial(); export const loginSchema = z.object({ email: z.string().email(), password: z.string(), }); ``` ```typescript // routes/users.ts import { route } from "@hectoday/http"; import { createUserSchema, updateUserSchema, userIdSchema } from "../schemas/user.ts"; const createUserRoute = route.post("/users", { request: { body: createUserSchema, }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const user = c.input.body; return Response.json({ id: "123", ...user }, { status: 201 }); }, }); const updateUserRoute = route.patch("/users/:id", { request: { params: z.object({ id: userIdSchema }), body: updateUserSchema, }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } const { id } = c.input.params; const updates = c.input.body; return Response.json({ id, ...updates }); }, }); ``` ## Error messages Zod provides detailed error messages: ```typescript // Request: POST /users // Body: { name: "", email: "invalid" } // Response: { "error": "Validation failed", "issues": [ { "path": ["name"], "message": "String must contain at least 1 character(s)" }, { "path": ["email"], "message": "Invalid email" } ] } ``` ## Custom error messages ```typescript route.post("/users", { request: { body: z.object({ name: z.string().min(1, "Name is required").max(100, "Name too long"), email: z.string().email("Invalid email address"), age: z.number().int("Age must be a whole number").min(18, "Must be 18 or older"), }), }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } return Response.json({ success: true }); }, }); ``` ## Type inference TypeScript automatically infers types from Zod schemas: ```typescript const userSchema = z.object({ name: z.string(), email: z.string().email(), age: z.number().int().positive(), role: z.enum(["admin", "user"]), }); route.post("/users", { request: { body: userSchema }, resolve: (c) => { if (!c.input.ok) { return Response.json({ error: c.input.issues }, { status: 400 }); } // TypeScript knows the exact type: // c.input.body: { // name: string; // email: string; // age: number; // role: "admin" | "user"; // } const { name, email, age, role } = c.input.body; }, }); ``` ## Adapter interface The validator adapter implements the `Validator` interface: ```typescript interface Validator { validate( schema: S, input: unknown, part: ValidationPart, ): ValidateResult, InferSchemaError>; } type ValidationPart = "params" | "query" | "body"; type ValidateResult = | { ok: true; value: T } | { ok: false; issues: readonly ValidationIssue[]; error?: TRawErr }; interface ValidationIssue { part: ValidationPart; path: readonly string[]; message: string; code?: string; } ``` ## Other schema libraries You can create adapters for other validation libraries: ### Valibot ```typescript import type { ValidationPart, Validator } from "@hectoday/http"; import * as v from "valibot"; export const valibotValidator: Validator = { validate(schema: S, input: unknown, part: ValidationPart) { const result = v.safeParse(schema, input); if (result.success) { return { ok: true as const, value: result.output }; } return { ok: false as const, issues: result.issues.map((issue) => ({ part, path: issue.path?.map((p) => String(p.key)) ?? [], message: issue.message, })), error: result.issues, }; }, }; ``` ### Yup ```typescript import type { ValidationPart, Validator } from "@hectoday/http"; import type { AnySchema, ValidationError } from "yup"; export const yupValidator: Validator = { validate(schema: S, input: unknown, part: ValidationPart) { try { const validated = schema.validateSync(input, { abortEarly: false }); return { ok: true as const, value: validated }; } catch (error) { if ((error as ValidationError).name === "ValidationError") { const validationError = error as ValidationError; return { ok: false as const, issues: validationError.inner.map((err) => ({ part, path: err.path?.split(".") ?? [], message: err.message, })), error: validationError, }; } throw error; } }, }; ``` ## Notes - Validator is **set once** in `setup()` and used for all routes - Schemas are defined **per route** in the `request` field - Validation happens **before guards** run - Use `c.input.ok` to check if validation passed - `c.input.issues` contains detailed error information - `c.raw` always contains unvalidated data - Type inference works automatically with Zod ## Why not built-in? Hectoday HTTP is validator-agnostic. Different projects use different schema libraries: - Some use Zod - Some use Valibot - Some use Yup - Some use custom validators - Some don't validate at all The adapter pattern lets you plug in any validator you want. Copy this adapter and modify for your validation library of choice. --- # Summary Hectoday HTTP is built on these principles: 1. **Describes facts, you decide meaning**: Validation and guards describe what happened. You choose the HTTP response. 2. **Explicit control flow**: Requests only end in guards (deny) or handlers (return). No hidden middleware. 3. **Web Standards foundation**: Uses Request/Response from the Fetch API. Runs on any runtime. 4. **Composition over configuration**: Build APIs by composing small pieces. No config files or decorators. 5. **Security as visible code**: Guards make security explicit. Audit by reading route definitions.