Skip to content

HTTP response

response is the second parameter every controller receives. It’s a helper-rich object whose job is to make picking the right HTTP outcome a one-line choice. Status codes live inside the helpers — response.notFound(...) already knows it’s a 404; you almost never set a status by hand.

This page is the complete surface, grouped by what you’re trying to do. Reach for it when autocomplete isn’t enough or you want to confirm a helper exists before writing it yourself.

A Response builds up state — body, headers, cookies, status — and then sends it. The helpers (success, notFound, redirect, sendFile) wrap the right combination of those primitives for you. Most controllers end with one helper call:

return response.success({ products });

A few rules the framework enforces:

  • First helper wins. Calling two terminal helpers from the same handler logs a warning; only the first one reaches the wire.
  • Streaming and SSE take over. Once you call response.stream() or response.sse(), you cannot follow up with response.success(...) — call .end() on the controller they return.
  • Return the helper. return response.success(...) — the return value drives the framework’s send pipeline.
import type { RequestHandler } from "@warlock.js/core";
export const showController: RequestHandler = async ({ request, response }) => {
// build up state via helpers
response.header("X-Total-Count", "42");
// terminate with one helper
return response.success({ ok: true });
};
MethodStatusWhen
response.success(data?)200normal read / update
response.successCreate(data)201resource created (POST)
response.accepted(data?)202async work accepted, not yet processed
response.noContent()204delete succeeded, no body needed
return response.success({ products: [...] });
return response.successCreate({ product });
return response.accepted({ jobId: enqueued.id });
return response.noContent();

response.success() (no argument) defaults to { success: true } — useful for void operations that still need a body.

MethodStatusWhen
response.badRequest(data)400malformed or invalid input
response.unauthorized(data?)401missing/invalid auth
response.forbidden(data?)403authenticated but not allowed
response.notFound(data?)404record missing
response.conflict(data?)409uniqueness violation, state conflict
response.unprocessableEntity(data)422semantic validation error
return response.badRequest({ error: t("validation.invalid") });
return response.unauthorized({ error: t("auth.invalidCredentials") });
return response.forbidden({ error: t("permission.denied") });
return response.notFound({ error: t("product.notFound") });
return response.conflict({ error: t("product.duplicateSku") });
return response.unprocessableEntity({ error: t("payment.cardDeclined") });

unauthorized, forbidden, and notFound each default to a { error: "<status name>" } payload when called with no argument — fine for quick guards, but usually you want a translated message.

Use the t() helper from @warlock.js/core for localized messages — it reads locale from the current request:

import { t } from "@warlock.js/core";
if (!result) {
return response.unauthorized({ error: t("auth.invalidCredentials") });
}

Real example from the reference codebase:

src/app/auth/controllers/login.controller.ts
import { t, type Request, type RequestHandler } from "@warlock.js/core";
import { type LoginSchema, loginSchema } from "../schema/login.schema";
import { loginUseCase } from "../use-cases/login.usecase";
export const login: RequestHandler<Request<LoginSchema>> = async ({ request, response }) => {
const result = await loginUseCase({
data: request.validated(),
deviceInfo: { userAgent: request.userAgent, ip: request.ip },
});
if (!result) {
return response.unauthorized({ error: t("auth.invalidCredentials") });
}
return response.success(result);
};
login.validation = { schema: loginSchema };
MethodStatusWhen
response.serverError(data)500unexpected failure
response.serviceUnavailable(data)503downstream is down, retry later
return response.serverError({ error: "Unexpected" });
return response.serviceUnavailable({
error: "Provider unavailable, retry in a minute",
});

You rarely call serverError directly — uncaught exceptions are mapped to it automatically by the framework’s error handler (see inject-request-context.ts). Reach for it only when you want to surface a specific upstream failure without throwing.

return response.redirect("/login"); // 302
return response.redirect("/new-home", 301); // status override
return response.permanentRedirect("/new-home"); // 301 shortcut

response.redirect(url, status?) sets the Location header and the status code. Default is 302 (temporary). Use 301 for permanent moves so search engines update their indexes.

A target taken from the request (?next=, returnTo, the Referer) is attacker-controlled: following it as-is is an open redirect, and a javascript: target runs script. Pass it through safeRedirect():

import { safeRedirect } from "@warlock.js/core";
return response.redirect(
safeRedirect(request.input("next"), {
allowedOrigins: ["https://app.example.com"],
fallback: "/dashboard",
}),
);

safeRedirect(target, options?) returns the target when it is a path on this origin (/posts/1?tab=2) or an absolute http:/https: URL whose origin is in allowedOrigins, and the fallback (default "/") otherwise. It refuses //host and /\host, other schemes, paths that do not start with /, credentials in the URL and any control character. isSafeRedirect(target, options?) is the boolean form. In pages, use safeRedirectTarget() from @warlock.js/web. warlock check reports request input reaching a redirect without one of them as WL9007.

// stream a file from disk
return response.sendFile("/abs/path/to/file.pdf");
// cache for 1 year (default)
return response.sendCachedFile("/abs/path/to/asset.css");
// send a Buffer with content type
return response.sendBuffer(buffer, { contentType: "image/png" });

SendFileOptions and SendBufferOptions cover the common knobs:

OptionTypeEffect
cacheTimenumberseconds for Cache-Control: max-age=...
immutablebooleanadds immutable to Cache-Control
inlinebooleantrue → render inline; false → download attachment
filenamestringdownload name (used in Content-Disposition)
contentTypestring(buffer only) MIME type
etagstring(buffer only) ETag header for conditional requests

sendFile (and sendBuffer / sendImage with an etag) answer conditional requests out of the box, in the RFC 9110 order:

  • If-Match uses strong comparison: only the exact strong tag (or *) passes; a weak W/"…" tag never does. A failed If-Match answers 412 Precondition Failed. Without If-Match, an If-Unmodified-Since older than the file does the same.
  • If-None-Match uses weak comparison: W/"abc" and "abc" match each other. A match answers 304 for GET and HEAD (412 for other methods).
  • If-Modified-Since is read only when If-None-Match is absent, at second precision.

If the path doesn’t exist, sendFile returns 404 with { error: "File Not Found" } automatically.

The same comparison is exported for your own handlers:

import { evaluateConditionalRequest, type RequestHandler } from "@warlock.js/core";
export const showDocumentController: RequestHandler = async ({ request, response }) => {
const etag = `"doc-42-v7"`;
const outcome = evaluateConditionalRequest({
method: request.method,
etag,
ifMatch: request.header("if-match"),
ifNoneMatch: request.header("if-none-match"),
});
if (outcome === "not-modified") return response.setStatusCode(304).send();
if (outcome === "precondition-failed") return response.setStatusCode(412).send();
return response.header("ETag", etag).success({ id: 42 });
};

matchesIfNoneMatch(header, etag) and matchesIfMatch(header, etag) expose the two comparisons on their own.

Public-folder and router.directory() files are answered by @fastify/static, which applies its own conditional-request handling to the weak ETags it generates.

For attachment-style downloads:

return response.downloadFile("/abs/path/report.pdf", "report-2026.pdf");
// or alias:
return response.download("/abs/path/report.pdf");

The framework sets Content-Type: application/octet-stream and Content-Disposition: attachment; filename=... for you.

response.xml(body, statusCode?) sends Content-Type: application/xml. The body is either a raw XML string or any XMLable, meaning any object with a toXML(): string method:

import type { XMLable } from "@warlock.js/core";
return response.xml('<?xml version="1.0" encoding="UTF-8"?><ok/>');
class Feed implements XMLable {
toXML() {
return '<?xml version="1.0" encoding="UTF-8"?><feed/>';
}
}
return response.xml(new Feed());

XMLable is matched by shape, so a package can satisfy it without importing core. A @warlock.js/sitemap Sitemap is one example:

import { Sitemap } from "@warlock.js/sitemap";
const sitemap = new Sitemap({ baseUrl: "https://example.com" }).add({ path: "/" });
return response.xml(sitemap);

Any other value throws. xml() is for bounded bodies only: a string or a single Sitemap, which is limited to 50,000 URLs. A SitemapIndex has no toXML(). It writes files that you serve directly. A Warlock web app does this for you through web.sitemap.

response.stream(contentType?, options?) returns a stream controller — for large dynamic payloads where buffering would blow memory, or for progressive rendering:

const stream = response.stream("text/plain");
stream.send("first chunk\n");
stream.send("second chunk\n");
stream.send("third chunk\n");
stream.end();

The controller’s surface:

MethodEffect
stream.send(data)write a chunk (string / Buffer) without waiting; ignored once the client left, throws after end()
stream.write(data)write a chunk and wait until the client can take more; resolves false once the stream ended or the client left
stream.render(reactElement)server-render a React node and write it as HTML
stream.drain()wait for buffered output to reach the client; resolves false once the stream ended or the client left
stream.backpressuredboolean — is the connection holding more than it should
stream.signalAbortSignal aborted when the client disconnects (not on end())
stream.onDisconnect(fn)run fn when the client disconnects
stream.end()finish the response
stream.endedboolean — has the stream ended (manually or via disconnect)

Once you call stream(), the response is “claimed” — don’t call success(...) or any other terminal helper afterward.

stream.send() does not wait. For a stream that can outrun its reader, loop on await stream.write(chunk) and stop when it resolves false — the same pattern as SSE below. Written chunks are not kept: after end(), response.body holds the last retainChunks chunks, none by default. Keep a short tail for logging or tests with response.stream("text/plain", { retainChunks: 20 }).

response.sse() is purpose-built for one-way server-to-client push — live notifications, progress updates, streaming LLM replies. Cheaper than websockets when the browser only needs to receive.

const sse = response.sse();
sse.send("message", { text: "Hello" });
sse.send("notification", { type: "info", body: "Update available" }, "msg-123");
sse.end();

The controller’s surface:

MethodEffect
sse.send(event, data, id?)emit an event (data JSON-stringified). id is optional, used by the browser’s Last-Event-ID.
sse.write(event, data, id?)emit an event and wait until the client can take more; resolves false once the client is gone
sse.drain()wait for buffered output to reach the client; resolves false once the stream ended or the client left
sse.backpressuredboolean — the transport is holding more than its high-water mark
sse.signalAbortSignal aborted when the client disconnects (not on end()) — pass it to the producer
sse.comment(text)write an SSE comment (invisible to the client) — useful for keep-alive pings
sse.onDisconnect(handler)register cleanup that runs when the client disconnects (cancel jobs, unsubscribe listeners)
sse.end()finish the stream
sse.endedboolean — has the stream ended (manually or via disconnect)

Browsers consume SSE via new EventSource(url). The framework already sets text/event-stream, Cache-Control: no-cache, and disables nginx buffering for you.

The event name and id are single-line fields, so line breaks (and NUL) in them are removed before they are written (toSingleLineSseField() is the same function, exported for your own SSE writers); a value built from user input cannot inject extra fields or events. data is always written one data: line per line, so it needs no escaping. A multi-line sse.comment() is written as one comment line per line. An onDisconnect handler that throws is logged and does not stop the handlers registered after it.

sse.send() does not wait: if the producer is faster than the client, the output piles up in memory. For a terminal, a log tail or any stream that can outrun its reader, use await sse.write(...) — it resolves once the client has caught up, and resolves false when the client is gone, so the loop stops:

const sse = response.sse();
for await (const line of tail(file, { signal: sse.signal })) {
if (!(await sse.write("line", { line }))) break;
}
sse.end();

Sent events are not kept. After end(), response.body holds the last retainEvents events, none by default, so a stream that runs for hours uses the same memory as one that runs for seconds. Keep a short tail for logging or tests with response.sse({ retainEvents: 20 }).

Real SSE example — streaming an AI reply

Section titled “Real SSE example — streaming an AI reply”

This is the actual stream controller from the reference codebase. It pipes an AI message’s chunks to the browser, cleans up on disconnect, and sends periodic keep-alives:

src/app/chat-messages/controllers/stream-ai-message.controller.ts
import { type Request, type RequestHandler } from "@warlock.js/core";
import { chatMessageEventBus, type AIStreamEvent } from "../events/chat-message.event-bus";
export const streamAiMessageController: RequestHandler = async ({ request, response }) => {
const aiMessageId = request.input("id");
const sse = response.sse();
const listener = (event: AIStreamEvent) => {
if (event.type === "chunk") {
sse.send("chunk", { chunk: event.chunk });
} else if (event.type === "done") {
sse.send("done", { model: event.model, usage: event.usage });
sse.end();
} else if (event.type === "error") {
sse.send("error", { message: event.message });
sse.end();
}
};
chatMessageEventBus.subscribe(aiMessageId, listener);
sse.onDisconnect(() => chatMessageEventBus.unsubscribe(aiMessageId, listener));
const keepAlive = setInterval(() => sse.comment("ping"), 25_000);
sse.onDisconnect(() => clearInterval(keepAlive));
};

Three things to notice:

  1. The listener decides which SSE event name to emit (chunk, done, error).
  2. sse.onDisconnect(...) is registered twice — once to unsubscribe from the event bus, once to clear the keep-alive interval. Both fire on client drop.
  3. Background jobs writing to a dead socket are a silent no-op — the framework swallows further sse.send(...) calls after disconnect.

If you’re producing the events yourself with @warlock.js/ai, the producer side looks like:

import { ai } from "@warlock.js/ai";
const sse = response.sse();
const run = await chatAgent.stream(request.input("message"));
for await (const event of run.events) {
if (event.type === "text-delta") {
sse.send("delta", { text: event.text });
} else if (event.type === "done") {
sse.send("done", { usage: event.usage });
sse.end();
}
}

See @warlock.js/ai/skills/subskills/agent.md for the agent’s event surface.

New in 5.12. response.streamReact(pipeableStream) pipes a React server stream — the PipeableStream returned by React’s renderToPipeableStream — onto the raw response. It’s the framework’s Stage 1 streaming SSR seam: @warlock.js/web uses it exclusively to stream a page document, and it never touches the raw response itself.

Reach for it when you’re writing a custom, non-page route (an API controller, not a .page.tsx file) that needs to stream a React tree instead of buffering a full HTML string first — a hand-rolled SSR endpoint outside the web page pipeline.

import { renderToPipeableStream } from "react-dom/server";
import type { PipeableReactStream, RequestHandler } from "@warlock.js/core";
import { Widget } from "./widget";
export const renderWidget: RequestHandler = async ({ response }) => {
const pipeableStream = await new Promise<PipeableReactStream>((resolve, reject) => {
const stream = renderToPipeableStream(<Widget />, {
onShellReady: () => resolve(stream),
onShellError: reject,
});
});
response.header("Content-Type", "text/html");
await response.streamReact(pipeableStream);
};

Two guarantees:

  • Status and headers are written before the first byte. streamReact calls the raw response’s writeHead(statusCode, headers) with whatever status and headers were already committed on response before piping starts — decide those first, then call streamReact.
  • A client disconnect aborts the render. If the connection closes before the stream finishes, the underlying React stream’s abort() is called, so a dropped connection doesn’t leave the render running to completion for nobody.

streamReact returns a Promise<void> that resolves once the response has finished sending, and rejects if the raw response errors while writing.

response.cookie("session_id", "abc.def.ghi", { raw: true });
response.cookie("preferences", { lang: "en", notifications: true }); // objects auto-JSON-stringified
response.cookie("theme", "dark", { httpOnly: false }); // deliberately JS-readable
response.clearCookie("session_id");

Cookie options come from Fastify’s @fastify/cookie (CookieSerializeOptions): domain, path, secure, httpOnly, sameSite, maxAge, expires, signed.

Since 4.10.0 every response cookie gets these unless you override them:

FlagDefaultWithout it
httpOnlytrueany injected script can read the cookie
sameSite"lax"the cookie rides along on cross-site requests
securetrue, except in developmentthe cookie travels in cleartext

secure is relaxed in development only — a browser drops a Secure cookie over plain http, which would silently break every local login. It stays on in test and staging.

These are the flags whose absence never fails a test and is fatal in production: the app works perfectly and is simply insecure. Making them the default means opting out is explicit and visible in review.

Lowest to highest: framework defaults → http.cookies.options → the per-call options argument.

src/config/http.ts
import { env } from "@warlock.js/core";
const httpConfigurations = {
cookies: {
secret: env("COOKIE_SECRET", "super-secret-key-change-me"),
options: {
// Framework defaults already cover httpOnly / sameSite / secure —
// set only what you want to differ app-wide.
path: "/",
},
},
};
response.header("X-Total-Count", "42");
response.header("Cache-Control", "no-cache");
response.headers({ "X-A": "1", "X-B": "2" }); // batch set
response.removeHeader("X-Internal");
const ct = response.getHeader("Content-Type");
const all = response.getHeaders();

header(name, value) chains — you can call it multiple times before the terminal helper.

A header value built from request input or stored user data goes through escapeHeaderValue() first. It removes CR, LF, NUL and other control characters (tabs stay), replaces characters outside ISO-8859-1 with ? and trims, so the value can neither split the header nor make Node throw ERR_INVALID_CHAR (a 500):

import { escapeHeaderValue } from "@warlock.js/core";
response.header("X-Export-Name", escapeHeaderValue(request.input("name")));

Most of the time the helper carries the status. When you genuinely need to set one by hand (custom 418, a 207 multi-status for batch jobs):

return response.setStatusCode(207).send({ results });

send(data, statusCode?) is the underlying terminal — every helper above is sugar over it. Avoid calling it directly when a helper exists; the helper’s semantic is what makes the controller readable.

const product = await createProductService(request.validated());
response.header("Location", `/products/${product.id}`);
return response.successCreate({ product });
const product = await getProductService(request.input("id"));
if (!product) {
return response.notFound({ error: t("product.notFound") });
}
return response.success({ product });
return response.sendFile("/var/data/report.pdf", {
cacheTime: 60 * 60,
inline: true,
filename: "report.pdf",
});
const stream = response.stream("text/plain");
for (const line of generateLines()) {
stream.send(line + "\n");
}
stream.end();
response.header("X-Cache", "MISS");
response.cookie("seen_at", new Date().toISOString());
return response.success({ data });
  • Don’t manually call send with a hand-rolled status when a helper exists. response.send(data, 404) works, but loses the error-event integration and reads worse than response.notFound(data). The helpers are the public API.
  • First helper wins. Two terminal calls log a warning; only the first one writes to the wire. If you find yourself wanting “send-or-fail-quietly” logic, branch with an if.
  • stream() and sse() consume the response. No success(...) afterward — call stream.end() / sse.end() to finish. Background work writing after end() is silently dropped.
  • Don’t set secure: Application.isProduction for cookies any more. Since 4.10.0 secure already tracks the environment — on everywhere except development. Writing it by hand is not just redundant, it’s narrower: it turns secure off in test and staging, where the default keeps it on.
  • response.success() with no arg returns { success: true }. If you want truly empty, use response.noContent() (204).
  • sendFile(path) checks file existence and returns 404 if missing. You don’t need to pre-fs.access it.
  • SSE keep-alives matter. Most proxies (nginx, Cloudflare) close idle connections after 30–60s. Send a sse.comment("ping") every 25 seconds for long-lived streams.
  • Controllers — where response.<helper>() is called from.
  • HTTP request — the other half of the handler signature.
  • Middleware — short-circuit a request by returning a response from middleware.

Stream wire format, headers and synchronous producers

Section titled “Stream wire format, headers and synchronous producers”
const sse = response.sse({
headers: { "Cache-Control": "private, no-store" },
maxQueuedBytes: 1024 * 1024,
onOverflow: "disconnect",
});
sse.send(undefined, { status: "ready" }); // no event field: EventSource.onmessage
sse.enqueue(undefined, "already serialized\nsecond line", undefined, { raw: true });

The positional send(event, data, id?, options?) and write(...) calls still work. An undefined event omits event:. Data is JSON by default; { raw: true } requires string data and emits one safe data: line per input line. Event/id line breaks and NUL are removed, so raw data cannot inject event fields.

Both sse({ headers }) and stream(contentType, { headers }) apply caller headers before flushing. Cache-Control keeps the safe stream default unless explicitly set in options.headers; a pre-existing global cache header does not override it. Caller protocol headers are filtered case-insensitively before applying overrides. Both helpers use chunked HTTP/1.1 framing, set their own content type, and preserve Connection: close during graceful drain. Content-Length cannot override framing.

Use synchronous enqueue() for event-bus producers. It returns false when closed or overflowing. The byte cap includes queued UTF-8 data and Node’s unsent buffer. It defaults to 1 MiB for enqueue; onOverflow: "disconnect" aborts the signal and runs disconnect cleanup, while "drop" rejects the newest chunk so the producer can schedule a resync. Every drop increments controller.dropped, including drops from send/write/render/comments. write() still returns false only when ended or disconnected; true means ready to continue, so inspect dropped when using drop mode. A single event exceeding the byte cap is always rejected (and disconnects in the default overflow mode). Choosing maxQueuedBytes also bounds send/write/render and SSE comments; using enqueue switches subsequent writes on that controller into bounded mode. drain() waits for both queues. end() stops accepting new output and flushes already accepted queued output before ending. Without a byte cap or enqueue, send/write retain their existing behavior.

Graceful HTTP/1 drain tracks raw responses, including hijacked replies. Before headers flush it adds Connection: close; already-streaming replies finish and then have their idle connections reaped. A hand-written hijacked writer that sets its own Connection header must preserve close during drain rather than overwrite it with keep-alive. Headers already on the wire cannot be changed.

On HTTP/2, SSE and stream helpers omit connection-specific and Transfer-Encoding headers. Graceful drain sends GOAWAY and closes sessions once active streams finish; HTTP/1.1 keeps its existing Connection: close behavior. HTTP/1.0 uses close-delimited responses without Transfer-Encoding. The shutdown timeout still bounds a stream that never finishes.

http.serverOptions: { http2: true } uses session draining by default. The framework maps idle-connection shutdown to session tracking on HTTP/2, so Fastify never receives its unsupported forceCloseConnections: "idle" setting.

http.errorMapper classifies thrown non-HttpError errors as { status, message?, payload? }. It does not see existing HttpError subclasses, returned validation errors, helper responses, unmatched routes, or global rate-limit failures.

Use http.errorResponse when the application needs one JSON error envelope across those paths:

src/config/http.ts
import type { HttpConfigurations } from "@warlock.js/core";
export default {
errorMapper: () => ({ status: 500 }),
errorResponse({ status, requestId }) {
return {
error: {
code: status >= 500 ? "serverError" : `http${status}`,
message: status >= 500 ? "Internal server error." : "Request failed.",
},
requestId,
};
},
} satisfies HttpConfigurations;

The synchronous formatter receives HttpErrorResponseContext: { status: number, body: unknown, requestId: string }. Narrow body before reading fields; it is the parsed, serialized JSON body. Return a JSON-serializable object or undefined to keep the original. The formatter changes only the body, preserving the HTTP status and headers except for recalculated content length, a private, no-store cache policy, and the correlation header. If it throws or cannot serialize its result, core logs the formatter error and retains the original body.

The hook covers status 400–599 with application/json or an application/*+json content type, including Fastify parsing errors, unmatched-route 404s, validation 422s, rate-limit 429s, and 500s. Warlock responses use the ID already stamped from request.id. Before Warlock creates a Request, the formatter receives Fastify’s request.id and echoes that same value under http.requestId.header (default X-Request-Id). Setting http.requestId.enabled: false suppresses that header; without a stamped Warlock ID, the formatter falls back to Fastify’s request ID even for routed responses. Keep request-ID headers enabled when the body must correlate with Warlock request logs. HTML pages, files, raw text, streams, and errors after headers have been sent are not reformatted. With no formatter configured, existing response shapes are unchanged.