Skip to main content
Every controller returns its reply through context.response, an IResponse object the framework hands you on the request context. You never construct a Response by hand: you call fluent builder methods (json, exception, notFound, redirect) that set the status, body, and headers, then the framework calls get() for you and sends a standard Web API Response to the client. Each builder returns the response instance, so calls chain, and the last write wins.

Why this object

  • Fluent and chainable. json(), exception(), notFound(), and redirect() each return IResponse, so you compose status, body, and headers in one expression.
  • Structured JSON envelope. JSON responses are wrapped in a consistent shape — success, status, message, data, error flags, and environment info — so every client parses replies the same way.
  • Web standards out. Internally get() produces a native Response; the body, status, and headers are exactly what a browser or fetch client expects.
  • Type-safe data. HttpResponse<Data> is generic over your payload type, so json(data) is checked against the shape you declared.
  • Headers and cookies built in. context.response.header exposes the full header API — set headers, attach cookies, manage caching — and they ride along with whatever body you set.
  • Socket-aware. A public done flag tracks completion for WebSocket responses without changing the HTTP body.

How it works

You mutate the response object during the request; the framework reads it afterward. Each builder method resets the others — calling redirect() clears any JSON body and sets the Location header, calling json() clears the redirect URL — so the response always reflects the last builder you called.
StageWhat happens
In the controllerYou call a builder on context.response (e.g. context.response.json(data, 201)), optionally setting headers and cookies on context.response.header.
Last write winsEach builder clears the state set by the others, so the final call determines the body, status, and Content-Type.
Framework resolvesThe framework calls get(env) on your response, which serializes the JSON envelope (or builds the redirect/stream) into a native Response.
Sent to clientThe Response — status, headers, body — is returned over the wire.

The response builder

These are the methods on IResponse (implemented by HttpResponse). The builder methods return the response instance for chaining; the inspector methods read its current state.
MethodPurpose
json(data, status?)Set a JSON body. status defaults to 200. Wraps data in the standard envelope.
exception(message, config?)Build an error response. status defaults to 500. config accepts key, data, and status.
notFound(message, config?)Build a 404 response. status defaults to 404, key defaults to "NOT_FOUND". config accepts key, data, and status.
redirect(url, status?)Set a Location header and an empty body. status defaults to 302 (Found). Accepts a string or URL.
stream(body, config?)Stream a ReadableStream, async iterable, or producer function. config accepts status and contentType.
sse(producer, config?)Stream Server-Sent Events from a producer function. Sets the text/event-stream headers.
headerThe IHeader instance — set response headers, cookies, and cache directives.
doneA public boolean flag, default false, for tracking WebSocket response completion.
getData()Read the currently set data payload, or null.
getStatus()Read the current status code.
isStream()true if a streaming/SSE body is set.
get(env?)Build and return the native Web API Response. The framework calls this for you.

The JSON envelope

json() does not send your data verbatim — it wraps it in a consistent envelope so every client reads success and error the same way. get(env) produces this body:
{
  key: string | null,        // optional machine-readable code (set via exception/notFound)
  data: Data,                // your payload, or {} when none
  message: string | null,    // human-readable message (set via exception/notFound)
  success: boolean,          // true when the status is a 2xx
  done: boolean,             // the socket completion flag
  status: number,            // the HTTP status code
  isClientError: boolean,    // true for 4xx
  isServerError: boolean,    // true for 5xx
  isNotFound: boolean,
  isUnauthorized: boolean,
  isForbidden: boolean,
  app: {
    env: "production" | "development" | ...  // the app environment
  }
}
The success, isClientError, and isServerError flags are derived from the status code automatically. A redirect() or stream()/sse() response skips this envelope: a redirect sends an empty body with the Location header, and a stream sends its raw bytes.

Returning JSON

Return data with the default 200, or pass a status code as the second argument — 201 when you create a resource. The controller receives context and returns the response object.
import type { ContextType } from "@ooneex/controller";
import { decorator, type IController } from "@ooneex/controller";

@decorator.controller({ path: "/users", method: "POST" })
export class CreateUserController implements IController {
  public async action(context: ContextType): Promise<IResponse> {
    const user = await createUser(/* ... */);

    // 201 Created, with the new resource in `data`
    return context.response.json({ user }, 201);
  }
}
A plain read returns the default 200:
return context.response.json({ users });

Returning errors

Use exception() for failures. The message is surfaced to the client; config lets you set the status, a machine-readable key, and extra data. The status defaults to 500.
// 500 Internal Server Error
return context.response.exception("Something went wrong");

// 400 with a key and structured detail
return context.response.exception("Validation failed", {
  status: 400,
  key: "INVALID_EMAIL",
  data: { field: "email", reason: "invalid format" },
});
For richer, typed error handling, throw a domain exception and let the framework’s exception layer format the response. See exceptions.

Returning a 404

notFound() is a dedicated helper for missing resources. The status defaults to 404 and the key defaults to "NOT_FOUND".
const user = await findUser(context.request.params.get("id"));

if (!user) {
  return context.response.notFound("User not found", {
    data: { id: context.request.params.get("id") },
  });
}

return context.response.json({ user });

Redirecting

redirect() sets the Location header and an empty body. The status defaults to 302 (Found); pass another redirect code for permanent moves or other semantics.
// 302 Found
return context.response.redirect("/login");

// 301 Moved Permanently
return context.response.redirect("/new-path", 301);

// A URL object works too
return context.response.redirect(new URL("https://example.com/v2"));

Streaming a response

stream() sends a body incrementally instead of buffering it. It accepts a ReadableStream, any async iterable of Uint8Array or string chunks, or a producer function that receives a writer and pushes chunks over time. config sets the status (default 200) and contentType (default application/octet-stream). A streamed response skips the JSON envelope and sends raw bytes. Pass an async iterable to stream values as they are produced:
async function* lines() {
  for await (const row of rows) {
    yield `${JSON.stringify(row)}\n`;
  }
}

return context.response.stream(lines(), {
  contentType: "application/x-ndjson",
});
Or pass a producer function. The writer exposes write(chunk), close(), and a signal (AbortSignal) that aborts when the client disconnects — check it to stop work early:
return context.response.stream(async (writer) => {
  for (const chunk of chunks) {
    if (writer.signal.aborted) break;
    await writer.write(chunk);
  }
  writer.close();
});
The stream closes automatically when the producer resolves, so writer.close() is optional — call it to end the stream before the producer returns.

Server-Sent Events

sse() streams Server-Sent Events to the client. It takes a producer function and automatically sets the text/event-stream, Cache-Control: no-cache, and Connection: keep-alive headers. config accepts a status (default 200). The writer’s send() accepts a string or an event object — { data, event?, id?, retry? }. Object or array data is JSON-encoded; comment() sends a keep-alive comment line, and signal aborts when the client disconnects.
return context.response.sse(async (writer) => {
  for await (const update of updates) {
    if (writer.signal.aborted) break;

    await writer.send({
      event: "update",
      id: update.id,
      data: { value: update.value },
    });
  }

  writer.close();
});
A plain send("message") emits a bare data: frame, while send({ data, event, id, retry }) sets the optional event, id, and retry fields. As with stream(), the connection closes when the producer resolves.

Headers and cookies

context.response.header is the full header API. Set headers before returning — they travel with whatever body you set.
context.response.header.set("X-Request-Id", requestId);
context.response.header.setCacheControl("public, max-age=3600");

return context.response.json({ users });
Attach cookies with setCookie(name, value, options?). Options cover path, domain, expires, maxAge, secure, httpOnly, and sameSite ("Strict" | "Lax" | "None").
context.response.header.setCookie("session", token, {
  path: "/",
  httpOnly: true,
  secure: true,
  sameSite: "Strict",
  maxAge: 60 * 60 * 24,
});

return context.response.json({ ok: true });
Use setCookies([...]) to set several at once, and removeCookie(name, options?) to expire one (it sends the cookie with an expired date and Max-Age=0).

Best practices

  • Return the response object. A controller’s action returns context.response; the framework calls get() and sends the native Response — don’t build a Response yourself.
  • Pick the right builder. json for data, exception for errors, notFound for missing resources, redirect for location changes. Each clears the others, so the last call wins.
  • Set the status that fits. 201 on create, 400/422 for bad input, 404 for missing — clients and the JSON envelope’s success/error flags depend on it.
  • Add a key to errors. A stable machine-readable key lets clients branch on error type without parsing the message.
  • Set headers before returning. Headers and cookies on context.response.header are read when the response is resolved, so configure them before the final builder call.
  • Prefer typed exceptions for control flow. For anything beyond a one-off error, throw a domain exception and let the exception layer shape the response consistently.
  • Controllers — where you build and return the response.
  • Exceptions — typed errors that the framework turns into error responses.
  • Routing — how a request reaches the controller that produces the response.