Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

HTTP

Request context

The Ctx object every handler, guard and middleware receives, how its fields are typed and parsed, and the limits that protect it.

On this page 13 sections

Every handler, guard, middleware and exception filter receives the same object for a request: the context, typed Ctx. It wraps Bun's Request and adds parsed parameters, query, headers and body, the client address, the matched route, per-request state and a draft of the response.

inspect.controller.ts
@Controller('/inspect')
@UseMiddleware(tenantMiddleware)
export class InspectController {
  @Get('/:id')
  show(ctx: Ctx<{ params: { id: string } }>) {
    return {
      id: ctx.params.id,
      tags: ctx.query.tag, // string, string[] or undefined
      agent: ctx.headers['user-agent'],
      tenant: ctx.state.tenant,
      route: ctx.route?.path,
    };
  }
}

#Fields

FieldTypeDescription
reqRequestThe original Bun request.
paramsRecord<string, string>Path parameters, percent-decoded. Replaced by the output of a params schema.
queryRecord<string, string | string[]>Query parameters, parsed on first access. Replaced by the output of a query schema.
headersRecord<string, string>Request headers with lowercase names, built on first access. Replaced by the output of a headers schema.
bodyunknownundefined unless the route has a body schema, in which case it is the validated body.
json()<T>() => Promise<T>Parses the body by Content-Type, once.
ipstring | undefinedThe client address, honoring listen({ trustProxy }).
securebooleantrue on a TLS listener, or when a trusted proxy reported X-Forwarded-Proto: https.
routeRouteInfo | nullThe matched route, or null.
metaReadonly<Record<PropertyKey, unknown>>The route's metadata, or an empty frozen object.
upgradeUpgradeInfo | null{ path } on a WebSocket upgrade to a registered endpoint, null otherwise.
stateCtxState & Record<PropertyKey, unknown>A fresh object per request, for middleware and guards to share data.
resResponseDraftThe response draft: status and headers.
serverServerThe Bun server that received the request.

params, query, headers and body are writable, which is how validation replaces them with the schema output. The rest are read-only.

#Typing the context

Ctx takes one type argument describing the inputs: Ctx<{ params?, query?, body?, headers? }> (the shape is CtxInput). Each key you leave out keeps its default type from the table above, and Ctx alone is Ctx<{}>.

The route decorators check the handler's ctx type against the route. For each input, the route supplies:

InputWithout a schemaWith a schema
paramsthe names in the path, as strings (PathParams<'/cats/:id'> is { id: string })the schema's output type
queryRecord<string, string | string[]>the schema's output type
headersRecord<string, string>the schema's output type
bodyundefinedthe schema's output type

A handler whose ctx type asks for more than the route supplies does not compile. For example, Ctx<{ body: { title: string } }> on a route without a body schema is a type error, because ctx.body is undefined there at runtime. Read an unvalidated body with await ctx.json() instead, or add a schema (see validation).

#Path parameters

ctx.params comes from Bun's router: one string per :name segment, percent-decoded. A wildcard's match is not included, and requests answered by the fallback (404 and 405) have {}. See paths and parameters.

#Query parameters

ctx.query is parsed from the URL the first time you read it:

  • A key that appears once is a string; a repeated key is an array of strings in order (?tag=a&tag=b gives { tag: ['a', 'b'] }). Type your code for both, or validate the query.
  • The object has a null prototype, and the keys __proto__, constructor and prototype are dropped, so the object never carries inherited or prototype-changing properties.
  • More than maxParams parameters (default 1000) is a 400 Bad Request with the detail The query string has more than 1000 parameters.. The raw query string is counted before it is parsed, so a huge query costs no more than the count.

#Headers

ctx.headers is a null-prototype object of the request headers with lowercase names. A header sent several times has its values joined with , , as Headers.get() does. Headers named __proto__, constructor or prototype are dropped, as in ctx.query. For the Headers object itself, use ctx.req.headers.

#The request body

ctx.json() reads and parses the body according to its Content-Type (matched case-insensitively, parameters such as ; charset=utf-8 ignored). It is memoized: every call for a request returns the same promise, so middleware, guards and the handler can all call it.

notes.controller.ts
@Controller('/notes')
export class NotesController {
  @Post('/')
  async create(ctx: Ctx) {
    const input = await ctx.json<{ text?: unknown }>();
    return { received: typeof input?.text === 'string' ? input.text : null };
  }
}
Content-TypeResult
application/json, or any +json type such as application/merge-patch+jsonThe parsed JSON, with unsafe keys removed (see prototype pollution). An empty body is undefined.
application/x-www-form-urlencodedA null-prototype object of strings; repeated fields become arrays.
multipart/form-dataA null-prototype object of strings and Files; repeated fields become arrays.
text/*The body as a string.
any other type415 Unsupported Media Type (Unsupported content type: application/xml), without reading the body.
none, with a non-empty body415 Unsupported Media Type (Unsupported content type: (none)).
none, with an empty body, or a request without a bodyundefined

Parse failures are client errors: malformed JSON is a 400 with the detail Malformed JSON body, an unparseable multipart body is a 400 with Malformed multipart body, and a body that cannot be read is a 400 with Failed to read request body.

ctx.json<T>() does not check the type argument: T is only a cast. Validate bodies you rely on with a body schema; then ctx.json() resolves to the validated value too, so unvalidated fields cannot slip back in through it.

#Limits

Every request is bounded before your code sees it. The limits are set on listen():

OptionDefaultOver the limit
maxRequestBodySize1 MiB (1048576 bytes)Bun answers 413 with an empty body, before any middleware runs.
maxParams1000 (DEFAULT_MAX_PARAMS)More query parameters: 400, The query string has more than 1000 parameters. More urlencoded or multipart fields: 413, The request body has more than 1000 fields.

Both parameter counts are taken before parsing: a query string or urlencoded body is counted by its non-empty &-separated fields, and a multipart body by its boundary delimiters, so oversized input is rejected without building it. maxParams must be an integer of at least 1; anything else makes listen() reject with HttpConfigError: maxParams must be an integer >= 1, got 0.. See the server for the other listen() options.

#Prototype pollution

Parsed request data never carries keys that would change an object's prototype when merged into it:

  • Query strings, urlencoded forms and multipart forms drop the keys __proto__, constructor and prototype, and are null-prototype objects.
  • JSON bodies drop __proto__ keys at every depth, and drop constructor and prototype keys whose value is an object (a constructor key holding a string is kept). Keys spelled with \u escapes, such as "\u005f\u005fproto\u005f\u005f", are dropped the same way.

So Object.assign(entity, await ctx.json()) cannot change entity's prototype. It can still set any other property, so validate the body and copy only the fields you expect.

The filtering pass runs only when the body text contains __proto__, constructor or prototype, or a \u00XX escape of a printable ASCII character; any other body gets a plain JSON.parse. A legitimate field named constructor or prototype that holds an object is dropped as well, so rename such fields in your API.

#Per-request state

ctx.state starts as an empty object for each request. Middleware and guards put what they learn there (the authenticated user, a tenant, a request id), and later layers read it. Type it once for the whole application by augmenting the CtxState interface:

ctx-state.d.ts
declare module '@knead-js/http' {
  interface CtxState {
    /** Set by tenantMiddleware from the X-Tenant header. */
    tenant?: string;
  }
}
tenant.middleware.ts
export const tenantMiddleware: MiddlewareFn = (ctx, next) => {
  ctx.state.tenant = ctx.headers['x-tenant'] ?? 'public';
  return next();
};

Make the properties optional: a property is only there once the middleware that sets it has run, and routes outside that middleware never see it.

#The response draft

ctx.res is a ResponseDraft: { status: number | undefined; headers: Headers }. Set ctx.res.status to override the route's status for this request, and add headers with ctx.res.headers. Both apply to the response built from the handler's return value, and the headers also fill in what a returned Response lacks (see return values).

The draft does not apply to error responses: when the handler throws, the problem response carries only the headers of the HttpException (its headers option). Put headers that an error response needs, such as Retry-After, on the exception.

#Route, metadata and upgrades

ctx.route is a frozen RouteInfo for the matched route:

PropertyTypeDescription
methodHttpMethodThe route's method, for example 'GET'.
pathstringThe route's path pattern, for example '/inspect/:id'.
controllerTypeThe controller class.
handlerstring | symbolThe handler method's name.
metaReadonly<Record<PropertyKey, unknown>>The route's metadata.

It is null for requests answered by the fallback (404, 405) and for WebSocket upgrades to an endpoint that has no GET route on its path. An upgrade to the path of a GET route has that route as ctx.route.

ctx.meta is ctx.route.meta, or an empty frozen object when there is no route. On a WebSocket upgrade it is the endpoint's meta merged over the route's. ctx.upgrade is { path } on an upgrade request to a registered endpoint and null on every other request, so a guard can tell the two apart. See WebSockets.

#Client address and TLS

ctx.ip is the socket peer's address in canonical form (IPv4-mapped IPv6 becomes IPv4, IPv6 is lowercased and compressed), or a trusted proxy's X-Forwarded-For entry when listen({ trustProxy }) is set. ctx.secure is true on a TLS listener, or when a trusted proxy sent X-Forwarded-Proto: https. With trustProxy off (the default), forwarding headers are ignored. See the server for how proxies are trusted.

#The request URL

Use requestUrl(ctx.req) rather than new URL(ctx.req.url) to get the URL as a URL. Bun gives a request whose Host header does not form a valid URL (such as Host: a b) a relative req.url, on which new URL() throws; requestUrl() resolves it against the placeholder origin http://host.invalid instead, so the path and query are always available.

See the @knead-js/http reference for the full Ctx type.