Controllers

Handle incoming HTTP requests and define your API routes.

Controllers are responsible for handling incoming HTTP requests and returning responses. A controller groups related routes under a common path prefix.

Defining a controller

Use the @Controller() decorator to define a controller and its path prefix:

import { Controller, Get, Post, type RequestContext } from '@miiajs/core'

@Controller('/users')
class UserController {
  @Get('/')
  list() {
    return [{ id: 1, name: 'Alice' }]
  }

  @Get('/:id')
  findOne(ctx: RequestContext) {
    return { id: ctx.params.id }
  }

  @Post('/')
  async create(ctx: RequestContext) {
    return await ctx.json()
  }
}

Controllers must be registered in a Module to be discovered by the framework.

Route decorators

MiiaJS provides decorators for all standard HTTP methods:

DecoratorHTTP Method
@Get(path?)GET
@Post(path?)POST
@Put(path?)PUT
@Patch(path?)PATCH
@Delete(path?)DELETE
@Head(path?)HEAD
@Options(path?)OPTIONS

The path argument is optional and defaults to '' (the controller prefix itself).

RequestContext

Every route handler receives a RequestContext object as its first argument:

interface RequestContext {
  req: Request               // Native Fetch API Request
  res: ResponseBuilder       // Fluent response builder
  params: Record<string, string>  // URL path parameters
  query: Record<string, string>   // Parsed query string
  rawQuery: URLSearchParams       // Raw query for multi-value params
  conn: ConnInfo                  // Transport-level connection info (lazy)
  ip: string | undefined          // Client IP (respects trustProxy)
  json<T = any>(): Promise<T>     // Parsed JSON body (cached)
  text(): Promise<string>         // Raw body as text (cached)
}

query, rawQuery, and conn are lazy-loaded on first access for better performance.

RequestContext is extensible via declaration merging. For example, @miiajs/auth adds the user property automatically when installed.

Client IP and connection info

ctx.conn is the transport-level connection info - { remoteAddress?, remotePort?, family? }. It always reflects the socket, is resolved lazily, and is cached per request. On runtimes or adapters that cannot provide it (for example serverless app.fetch usage) it is an empty object.

ctx.ip is the client IP. By default it equals ctx.conn.remoteAddress; behind a reverse proxy that is the proxy's address, so tell the app which headers to trust:

new Miia({ trustProxy: true })                              // leftmost X-Forwarded-For entry
new Miia({ trustProxy: 'cf-connecting-ip' })                // a single trusted header (Cloudflare)
new Miia({ trustProxy: ['cf-connecting-ip', 'x-real-ip'] }) // first present, in priority order
Enable trustProxy only when the app actually sits behind a trusted proxy. With trustProxy: true the leftmost X-Forwarded-For entry is client-controlled whenever the proxy appends to the header instead of overwriting it - prefer your edge's vendor header (cf-connecting-ip, x-real-ip, fly-client-ip).

ctx.conn.remoteAddress always stays the honest socket address regardless of trustProxy. The default client key in @miiajs/rate-limit is ctx.ip.

Route parameters

Dynamic segments in the path are extracted as ctx.params:

@Get('/:userId/posts/:postId')
getPost(ctx: RequestContext) {
  const { userId, postId } = ctx.params
  return { userId, postId }
}

Response handling

Route handlers can return values in several ways:

Auto JSON

Return any object or array and it will be serialized as JSON with status 200:

@Get('/')
list() {
  return [{ id: 1 }, { id: 2 }]
}

Custom status

Use the @Status() decorator to set a different status code:

import { Status } from '@miiajs/core'

@Post('/')
@Status(201)
create(ctx: RequestContext) {
  return { id: 3, created: true }
}

Native Response

Return a Response object for full control:

@Get('/download')
download() {
  return new Response('raw body', {
    status: 200,
    headers: { 'Content-Type': 'text/plain' },
  })
}

Response builder

Use ctx.res for a fluent API:

@Get('/html')
page(ctx: RequestContext) {
  ctx.res
    .status(200)
    .header('X-Request-Id', 'abc')
    .html('<h1>Hello</h1>')
}

See Response for more details.

Validation

MiiaJS provides built-in validation decorators that work with any ZodLike schema (Zod, or any object with safeParse()):

import { ValidateBody, ValidateQuery, ValidateParams } from '@miiajs/core'
import { z } from 'zod'

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
})

const QuerySchema = z.object({
  page: z.string().transform(Number).default('1'),
  limit: z.string().transform(Number).default('10'),
})

type CreateUserInput = z.infer<typeof CreateUserSchema>

@Controller('/users')
class UserController {
  @Post('/')
  @Status(201)
  @ValidateBody(CreateUserSchema)
  async create(ctx: RequestContext) {
    // After @ValidateBody runs, ctx.json<T>() returns the validated (and possibly transformed) data.
    const data = await ctx.json<CreateUserInput>()
    return data
  }

  @Get('/')
  @ValidateQuery(QuerySchema)
  list(ctx: RequestContext) {
    return { page: ctx.query.page, limit: ctx.query.limit }
  }
}

Handler methods that read the request body must be async, since ctx.json() returns a Promise.

Validation decorators throw UnprocessableException (422) with detailed error messages on failure.

DecoratorValidatesSource
@ValidateBody(schema)Request bodyawait ctx.json<T>() (validated after decorator runs)
@ValidateQuery(schema)Query parametersctx.query
@ValidateParams(schema)Path parametersctx.params

Body size limits

Every app enforces a request body limit of 1 MiB by default. A request whose declared Content-Length exceeds the route's limit gets a 413 Payload Too Large response before the handler runs. Configure the default app-wide and override per controller or per route with @BodyLimit(bytes):

import { Miia, BodyLimit } from '@miiajs/core'

// App-wide default (bytes). `false` disables the limit entirely.
const app = new Miia({ maxBodySize: 512 * 1024 })

@Controller('/files')
@BodyLimit(5 * 1024 * 1024) // all routes in this controller: 5 MiB
class FileController {
  @Post('/avatar')
  @BodyLimit(2 * 1024 * 1024) // this route only: 2 MiB (method beats class)
  async uploadAvatar(ctx: RequestContext) {
    const form = await ctx.req.formData()
    // ...
  }

  @Post('/meta')
  async meta(ctx: RequestContext) {
    return ctx.json() // controller-level 5 MiB applies
  }
}

Precedence: method-level @BodyLimit > class-level @BodyLimit > maxBodySize app option. A method-level @BodyLimit applies even when the app sets maxBodySize: false.

Chunked bodies (no Content-Length) cannot be matched to a route before they are read, so they are capped by the adapter ceiling - the maximum of maxBodySize and all @BodyLimit values across the app - rather than the per-route limit. The per-route limit applies to the declared Content-Length.

How the cap is enforced per runtime:

RuntimeMechanism
BunBun.serve({ maxRequestBodySize }) - native, set automatically by listen()
DenoA counting stream wrapper applied in listen() for bodies without Content-Length
Node.js (@miiajs/node-server, @miiajs/uws-server)Early 413 on Content-Length + in-stream byte cap for chunked bodies
On Bun, a body rejected natively by maxRequestBodySize returns Bun's own 413 response, not the MiiaJS JSON envelope ({ statusCode, error, message }). Clients that parse error bodies will see this difference between runtimes.