Core Concepts

Context

A guide to middleware context in Raptor

Introduction

The context object is passed as the first parameter to a middleware function. It includes the HTTP request, the HTTP response, and any additional properties added by previous middleware calls.

Usage

import { Kernel } from "@raptor/kernel";
import type { Context } from "@raptor/types";
 
const app = new Kernel();
 
app.use((context: Context) => {
  const { request, response } = context;
 
  console.log(request, response);
 
  return "Dinosaurs eat man. Woman inherits the earth";
});
 
app.serve();

Request

Raptor uses the standard Web API Request object, giving you access to all its built-in properties and methods without any framework-specific wrappers.

app.use((context: Context) => {
  const { request } = context;
 
  console.log(request.method);
  console.log(request.url);
  console.log(request.headers);
 
  return "Life, uh, finds a way";
});

Common Request Operations

Reading Headers

app.use((context: Context) => {
  const authToken = context.request.headers.get("Authorization");
  const contentType = context.request.headers.get("Content-Type");
 
  return { authToken, contentType };
});

Parsing Request Body

app.use(async (context: Context) => {
  // JSON body
  const data = await context.request.json();
 
  // Form data
  const formData = await context.request.formData();
 
  // Plain text
  const text = await context.request.text();
 
  return { received: data };
});

Reading Query Parameters

app.use((context: Context) => {
  const url = new URL(context.request.url);
 
  const name = url.searchParams.get("name");
  const page = url.searchParams.get("page") || "1";
 
  return { name, page };
});

Checking Request Method

import { MethodNotAllowed } from "@raptor/kernel";
 
app.use((context: Context) => {
  if (context.request.method === "POST") {
    // Handle POST request
  }
 
  if (context.request.method !== "GET") {
    throw new MethodNotAllowed();
  }
 
  return "Clever Girl";
});

Since Raptor doesn't wrap or modify the Request object, you can use any Web API features directly - no need to learn framework-specific methods.

Connection

The Web API Request object says nothing about the network the request arrived on, so Raptor exposes that separately as context.connection. Each server adapter reads the peer from its own runtime - Deno, Bun and Node all report a socket - and normalises it into the same shape.

app.use((context: Context) => {
  const { connection } = context;
 
  return {
    address: connection?.remote.address,
    port: connection?.remote.port,
    type: connection?.remote.addressType,
  };
});

The connection object

PropertyTypeDescription
remote.addressstringThe remote IP address, in canonical form
remote.portnumberThe remote port
remote.addressType"IPv4" | "IPv6"The address family

Addresses are canonicalised before they reach you, so the same host always arrives as the same string - ::ffff:127.0.0.1 is reported as 127.0.0.1, and 2001:0db8::0001 as 2001:db8::1. That makes the value safe to compare, log, or use as a cache or rate-limit key without normalising it yourself. An address the parser doesn't recognise is dropped rather than passed through.

When there is no connection

context.connection is optional, and so is every field on it, because not every request arrives over a socket the runtime can describe:

  • A request over a Unix socket has no network address.
  • A serverless deployment has no socket at all. The Lambda adapter fills in the caller's address from the API Gateway event, which means there is an address but never a port.
  • Middleware tested by calling it directly, or a request dispatched in-process, has no connection.

Always treat it as absent-by-default and reach for it optionally:

app.use((context: Context) => {
  const address = context.connection?.remote.address;
 
  if (!address) {
    return { message: "You didn't say the magic word" };
  }
 
  return { address };
});

Behind a proxy

context.connection is deliberately the socket peer, and nothing else - it is the one address the runtime can vouch for. Run behind a load balancer, a CDN, or an ingress controller and that address will be the proxy, not the client.

To resolve the client, use the clientAddress helper. It starts from the socket peer and walks the X-Forwarded-For chain outwards while each hop is trusted, returning the first address it can't account for. Anchoring on the socket is what stops a forged header reaching past your trust boundary.

import { clientAddress } from "@raptor/kernel";
 
app.use((context: Context) => {
  // Trusts nothing, so this is just the socket peer.
  const socket = clientAddress(context);
 
  // Trusts hops inside the given ranges, stopping at the first that isn't.
  const client = clientAddress(context, ["10.0.0.0/8", "172.16.0.0/12"]);
 
  return { socket, client };
});

The second argument is the trust policy:

PolicyBehaviour
false (default)Ignore X-Forwarded-For entirely and use the socket peer
string[]Trust hops falling inside these CIDR ranges, stopping at the first that isn't
numberTrust that many proxy hops, counting out from the socket

Prefer the CIDR list. A hop count checks no addresses, so it is only safe where your application cannot be reached except through exactly that many proxies - if a request can also arrive by a shorter path, such as an exposed origin or a second ingress, then whoever sends the header picks the address you get back. There is no "trust everything" setting, and a range matching every address is rejected, for the same reason.

Compiling the policy once

A policy passed to clientAddress is revalidated on every call. Where you use one address per request, compile it at start-up with compileTrust instead. An invalid range then throws while the application is booting, rather than on a request:

import { clientAddress, compileTrust } from "@raptor/kernel";
 
const trusted = compileTrust(["10.0.0.0/8"]);
 
app.use((context: Context) => {
  const address = clientAddress(context, trusted);
 
  return { address };
});

Response

In Raptor, at least one middleware function is required to return a body response to ensure that the request cycle completes successfully. Raptor automatically handles response types through its response processor system. By default, it can process JSON objects, HTML strings, plain text, and raw Response objects. However, you can extend this system with your own custom processors.

Returning JSON

If the middleware response body is an object, it will be automatically recognized as application/json. Consequently, both the Content-Type header and the response body will be appropriately set to reflect this format.

app.use(() => ({
  name: "Dr Ian Malcolm",
}));

Returning HTML

When a string is returned, the Content-Type header is automatically set to text/plain. However, if the string is detected to contain HTML, the Content-Type header will be automatically adjusted to text/html.

app.use(() => "<h1>Hello, Dr Malcolm!</h1>");

Returning Response Directly

Raptor makes it easy to return simple scalar values as responses, but when you need full control over the output, you can also return a custom Response object directly.

app.use(() => new Response("We spared no expense", { status: 200 }));

Overriding headers

Although it's convenient to return data without configuring a Content-Type, there may be instances where you need to specify a particular header. In such cases, you can proceed as follows:

app.use((context: Context) => {
  context.response.headers.set("Content-Type", "application/hal+json");
 
  return {
    name: "That doesn't look very scary. More like a six-foot turkey.",
  };
});

Extending

Response Processors

What are Response Processors?

The Response Manager handles converting your middleware return values into HTTP responses. There are four built-in processors out of the box, but you can add custom ones or override the defaults.

Built-in Processors
TypeHandlesContent-Type
responseResponse objects(preserved from original)
errorError instancesBased on request Accept header
stringString valuestext/plain or text/html
objectObjects/Arraysapplication/json
Creating a custom processor

To create a custom processor, implement the Processor interface:

import type { Context } from "@raptor/types";
import type { Processor, ResponseBodyType } from "@raptor/kernel";
 
export default class CustomStringProcessor implements Processor {
  process(body: any, context: Context): Response {
    return new Response("custom string processor: " + body, {
      status: context.response.status,
      headers: context.response.headers,
    });
  }
}
Overriding an existing processor

Configuration:

It's easy to override existing processors using configuration-based replacement:

import type { Context } from "@raptor/types";
import { Kernel, ResponseManager } from "@raptor/kernel";
 
import CustomStringProcessor from "./custom-string-processor.ts";
 
const app = new Kernel({
  response: {
    processors: {
      string: new CustomStringProcessor(),
    },
  },
});
 
app.use(() => "Hello, Dr Malcom!");
 
app.serve();
 
// custom string processor: Hello, Dr Malcolm!

Using the class methods:

Or if you prefer class-based methods, you can do the following:

import type { Context } from "@raptor/types";
import { Kernel, ResponseManager } from "@raptor/kernel";
 
import CustomStringProcessor from "./custom-string-processor.ts";
 
const app = new Kernel();
 
const manager = new ResponseManager();
 
manager.register("string", new CustomStringProcessor());
 
app.setResponseManager(manager);
 
app.use(() => "Hello, Dr Malcom!");
 
app.serve();
 
// custom string processor: Hello, Dr Malcolm!

All strings which are returned from middleware will now use your custom processor.

© 2026 Raptor