Koa Response API 深度解析:从状态码、Body 序列化到头控与重定向
Koa 的 Response 对象是对 Node.js 原生 res 对象的封装层,它让“写 HTTP 响应”这件日常操作从手动拼头、手动序列化变成了声明式的属性赋值。本文基于 Koa 仓库(当前版本 3.2.1,engines 要求 Node >= 18)中的 Response API 文档 展开,逐条讲清每个 API 的语义与默认行为,并结合 lib/response.js 与 lib/application.js 的源码,剖析状态码自动协商、ctx.body 类型分发、重定向安全处理等底层机制,帮助你在写中间件和代理逻辑时做出准确的响应控制决策。
Response 对象在哪里、ctx 上的快捷方式从哪来
官方文档的开篇定义:Response 是对 Node 原生 response 对象的抽象,为日常 HTTP 服务开发提供额外能力。在 Koa 中,每个请求上下文 ctx 同时挂有 ctx.request 与 ctx.response,而你在代码里写的 ctx.status、ctx.body、ctx.set(...) 等,其实都是对这两个对象的委托(delegation)。
从源码结构看,lib/context.js 通过 delegates 包在 context 原型上批量注册了这些快捷方式:
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('has')
.method('set')
.method('append')
.method('flushHeaders')
.method('back')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable')
这意味着 ctx.redirect('/login') 与 ctx.response.redirect('/login') 完全等价;ctx.body 的 getter/setter 则直接转发到 ctx.response._body。本文后续以 response 为叙述主体,实际使用中写在 ctx 上即可。
response.status 与 response.message:为什么默认是 404
response.status 读取 this.res.statusCode(lib/response.js)。文档特别强调了一个与 Node 的差异:Node 的 res.statusCode 默认是 200,而 Koa 默认是 404。
这个 404 的默认值来自 lib/application.js 的 handleRequest:
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404
const onerror = (err) => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
每个请求进入中间件链之前,状态码先被强制置为 404——即“若没有任何中间件明确设置状态或 body,客户端最终收到 404”。这正是 Koa 的设计哲学:响应必须由应用主动声明。
设置状态码时,setter 有严格校验(lib/response.js):
set status (code) {
if (this.headerSent) return
assert(Number.isInteger(code), 'status code must be a number')
assert(code >= 100 && code <= 999, `invalid status code: ${code}`)
this._explicitStatus = true
this.res.statusCode = code
if (this.req.httpVersionMajor < 2) this.res.statusMessage = statuses.message[code]
if (this.body && statuses.empty[code]) this.body = null
}
几个值得注意的细节:
- 状态码必须是 100–999 的整数,否则直接抛错。
statuses包维护了完整的状态码/消息映射表,文档列出的 1xx–5xx 全量清单(从100 "continue"到511 "network authentication required")正是来自它; _explicitStatus是内部标志,记录“状态码是用户显式设置的”——它决定了后文ctx.body赋值时是否要自动改状态码;- 若设置的状态属于
statuses.empty(如 204、304)而当前已有 body,Koa 会把 body 强制置为null,因为空响应不允许携带内容; - HTTP/1.x 请求下会顺带把
statusMessage设为标准文案。
response.message 默认随 response.status 联动:getter 返回 this.res.statusMessage || statuses.message[this.status](lib/response.js),你也可以通过 response.message = 'msg' 显式覆盖。
因此,若想在不发送 body 的情况下返回非 404 状态,必须显式赋值:
ctx.response.status = 200;
// 或任何其它状态
ctx.response.status = 204;
response.body:Koa 响应体系的核心
response.body 是 Koa 自动序列化机制的入口。文档给出的支持类型是:
string:直接写入Buffer:直接写入Stream:管道写入Object/Array:JSON 序列化null/undefined:无内容响应
而当前仓库的实现(lib/response.js)实际上还支持 ReadableStream、Blob 和 Response 三种 WhatWG 类型,这是文档未逐一列出但源码已确认的能力。setter 的完整逻辑如下(节选关键分支):
set body (val) {
const original = this._body
this._body = val
// no content
if (val == null) {
if (!statuses.empty[this.status]) {
if (this.type === 'application/json') {
this._body = 'null' // JSON 上下文的 null 会序列化为字符串 "null"
return
}
this.status = 204
}
if (val === null) this._explicitNullBody = true
this.remove('Content-Type')
this.remove('Content-Length')
this.remove('Transfer-Encoding')
cleanupPreviousStream()
return
}
// set the status
if (!this._explicitStatus) this.status = 200
// set the content-type only if not yet set
const setType = !this.has('Content-Type')
// string
if (typeof val === 'string') {
if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text'
this.length = Buffer.byteLength(val)
return
}
// buffer
if (Buffer.isBuffer(val)) {
if (setType) this.type = 'bin'
this.length = val.length
return
}
// stream
if (isStream(val)) {
onFinish(this.res, destroy.bind(null, val))
// ...
if (setType) this.type = 'bin'
return
}
// ReadableStream / Blob / Response 分支从略
// json
this.remove('Content-Length')
if (!this.type || !/\bjson\b/i.test(this.type)) this.type = 'json'
}
对照文档,各类型的具体行为及源码级补充如下。
String
文档说明:Content-Type 默认为 text/html 或 text/plain(charset 均为 utf-8),并设置 Content-Length。源码揭示了一个文档未细说的规则:以空白字符后紧跟 < 开头(即 ^\s*<)的字符串被识别为 HTML,type 设为 'html',否则为 'text'(lib/response.js)。测试 tests/response/body.test.js 中有对应的 when an html string is given 与 when an xml string is given 用例印证了这一点。
Buffer
文档说明:Content-Type 默认为 application/octet-stream,并设置 Content-Length。源码中 setType 时执行 this.type = 'bin'(lib/response.js)。
Stream
文档说明:Content-Type 默认为 application/octet-stream,且每当 Stream 被设为 body 时,.onerror 会自动监听 error 事件;请求关闭(包括提前关闭)时 Stream 会被 destroy。源码实现为 onFinish(this.res, destroy.bind(null, val))(lib/response.js)。
若你不想要自动 error 转发与自动 destroy(例如代理场景下直接透传上游 HTTP 流时,destroy 会切断底层连接),文档给出的方案是:不要把上游流直接作为 body,而是手动接管 error 后再 pipe:
const PassThrough = require('stream').PassThrough;
app.use(async ctx => {
ctx.body = someHTTPStream.on('error', (err) => ctx.onerror(err)).pipe(PassThrough());
});
此外源码还处理了流替换的边角情况:当 body 从一个 stream 换成非 stream 值时会 destroy(original),换成新 stream 时则不销毁旧流(以支持 koa-compress 这类包装流中间件),这些行为在 tests/response/body.test.js 中都有“should NOT cleanup original stream when replaced by new stream”等专门用例覆盖。
Object / Array
文档说明:Content-Type 默认为 application/json,涵盖普通对象 { foo: 'bar' } 与数组 ['foo', 'bar']。最终序列化发生在响应发送阶段(见文末 respond 小节)。
null / undefined 与 204 的自动协商
文档中这段说明是理解 ctx.body 的关键:
若
response.status未被设置,Koa 会根据response.body自动把状态设为200或204。具体而言,如果 body 未设置或为null/undefined,Koa 自动把状态设为204。若你真要用其它状态发送无内容响应,需按下面的方式覆盖 204。
// 必须先于 status 设置,因为 null | undefined body 会自动把状态置为 204
ctx.body = null;
// 现在用期望的状态覆盖 204
ctx.status = 200;
源码印证了顺序敏感性:set body(null) 中 this.status = 204 会触发 status setter 把 _explicitStatus 置为 true;随后再写 ctx.status = 200 才能最终生效。反过来,先写 ctx.status = 200 再写 ctx.body = null,由于此时 statuses.empty[200] 为假而状态已是 200,body 分支会走 this._body = 'null'(Content-Type 为 json 时)或直接维持 200——两种写法的产物不同,务必按文档给出的“先 body 后 status”顺序操作。
官方建议:用中间件断言 body 类型
文档明确指出:Koa 不会拦截一切可能被赋给 body 的值——函数没有有意义的序列化结果、布尔值在你的应用里可能合理、Error 对象虽然能工作但部分属性不可枚举而可能不符合预期。官方推荐的对策是添加一个开发期断言中间件:
app.use(async (ctx, next) => {
await next()
ctx.assert.equal('object', typeof ctx.body, 500, 'some dev did something wrong')
})
response.length:Content-Length 的读写
- setter(lib/response.js):设置 Content-Length,但若响应已带 Transfer-Encoding 头则跳过——两者互斥,这是 HTTP 语义的硬约束:
set length (n) {
if (!this.has('Transfer-Encoding')) {
this.set('Content-Length', n)
}
}
- getter(lib/response.js):优先解析已存在的
Content-Length头;否则从ctx.body推断——string 用Buffer.byteLength、Buffer 直接取length、其它对象用Buffer.byteLength(JSON.stringify(body))估算;stream 或空 body 返回undefined。文档描述为“返回 number、推断值或 undefined”,源码完全一致。
响应头操作:header / get / has / set / append / remove
response.header 与 response.headers
两者都返回响应头对象。实现上优先调用 res.getHeaders(),并保留了 Node < 7.7 的 res._headers 兼容路径(lib/response.js):
get header () {
const { res } = this
return typeof res.getHeaders === 'function'
? res.getHeaders()
: res._headers || {} // Node < 7.7
}
headers 是 header 的别名。response.socket 则直接返回 this.res.socket(net.Socket 实例)。
response.get(field)
大小写不敏感地读取某个响应头:
const etag = ctx.response.get('ETag');
实现即 this.res.getHeader(field)(lib/response.js)。
response.has(field)
判断某个响应头是否已设置,匹配大小写不敏感:
const rateLimited = ctx.response.has('X-RateLimit-Limit');
实现优先 res.hasHeader,并保留了旧 Node 的下落路径(lib/response.js)。set body 内部就靠 this.has('Content-Type') 决定要不要自动设置 Content-Type——用户已显式设置的 Content-Type 永远不会被 body 赋值覆盖,这一行为有 tests/response/body.test.js 中 “when Content-Type is set / should not override” 用例支撑。
response.set(field, value) 与 response.set(fields)
单头与批量两种形式:
ctx.set('Cache-Control', 'no-cache');
ctx.set({
'Etag': '1234',
'Last-Modified': date
});
实现(lib/response.js):
set (field, val) {
if (this.headerSent || !field) return
if (typeof field === 'string') {
if (field.toLowerCase() === 'content-type') {
assert(!Array.isArray(val), 'Assign multiple Content-Type for response header is not allowed')
}
this.res.setHeader(field, val)
} else {
Object.keys(field).forEach(header => this.res.setHeader(header, field[header]))
}
}
- 头已发送(
headerSent)后调用是静默无效的,不会抛错; - 底层委托给
res.setHeader,即按 key 设置或更新,而不会重置整个头集合(文档原话:delegates to setHeader which sets or updates headers by specified keys and doesn't reset the entire header); - 额外约束:
Content-Type不允许被赋成数组(多头 Content-Type 无意义),违反会抛断言错误。
response.append(field, value)
追加一个头的值,与已有值合并为数组(lib/response.js):
ctx.append('Link', '<http://127.0.0.1/>');
典型用于 Set-Cookie、Link、Warning 等允许多值的头。
response.remove(field)
移除响应头,headerSent 后静默返回:
ctx.response.remove('ETag');
response.type 与 response.is:MIME 类型
response.type
返回剥离了 charset 等参数后的 Content-Type:
const ct = ctx.type;
// => "image/png"
实现为 type.split(';', 1)[0](lib/response.js)。
response.type=
通过 mime 字符串或文件扩展名设置 Content-Type:
ctx.type = 'text/plain; charset=utf-8';
ctx.type = 'image/png';
ctx.type = '.png';
ctx.type = 'png';
setter 内部走 mime-types 的 contentType(type) 做解析(lib/response.js):解析成功就 set('Content-Type', ...),失败则 remove('Content-Type')。文档提醒:合适的类型会自动附带 charset(例如 response.type = 'html' 会得到 utf-8),若需要精确覆盖 charset,应直接用 ctx.set('Content-Type', 'text/html') 绕过自动补全。
response.is(types...)
与 ctx.request.is() 类似,判断响应类型是否属于给定类型之一,底层是 type-is 包(lib/response.js)。文档给出的经典用例是“压缩所有非流的 HTML 响应”的中间件:
const minify = require('html-minifier');
app.use(async (ctx, next) => {
await next();
if (!ctx.response.is('html')) return;
let body = ctx.body;
if (!body || body.pipe) return;
if (Buffer.isBuffer(body)) body = body.toString();
ctx.body = minify(body);
});
response.redirect 与 response.back:重定向与安全处理
response.redirect(url)
执行 [302] 重定向:
ctx.redirect('/login');
ctx.redirect('http://google.com');
要改变默认 302,在调用前后赋值状态即可;要改变 body,在调用之后赋值:
ctx.status = 301;
ctx.redirect('/cart');
ctx.body = 'Redirecting to shopping cart';
源码(lib/response.js)做了两件文档没有明说的事:
redirect (url) {
if (/^https?:\/\//i.test(url)) {
// formatting url again avoid security escapes
url = new URL(url).toString()
}
this.set('Location', encodeUrl(url))
// status
if (!statuses.redirect[this.status]) this.status = 302
// html
if (this.ctx.accepts('html')) {
url = escape(url)
this.type = 'text/html; charset=utf-8'
this.body = `Redirecting to ${url}.`
return
}
// text
this.type = 'text/plain; charset=utf-8'
this.body = `Redirecting to ${url}.`
}
- URL 归一化与防注入:绝对 http(s) URL 会经
new URL(url).toString()重新格式化,避免反斜杠等字符造成的 Location 头注入;相对/绝对 URL 最后都经encodeUrl编码(中文、emoji 等自动百分号编码); - 状态码协商:仅当当前状态码不属于重定向族(
statuses.redirect,即 301/302/303/307/308)时才置 302——这就是“先ctx.status = 301再redirect可以生效”的底层原因; - 响应体协商:客户端 Accept 含 HTML 时返回 HTML 文本并做
escape防 XSS,否则返回纯文本。测试文件 tests/response/redirect.test.js 覆盖了http://google.com\@apple.com的归一化、emoji URL 编码、HTML 接受时 body 内容等场景。
response.back(url)
类似 redirect,但先检查 Referrer 头。v3 新增——v2 中 redirect('back', alt) 的特例被移除后,此方法成为推荐写法。源码逻辑(lib/response.js):
back (alt) {
const referrer = this.ctx.get('Referrer')
if (referrer) {
// referrer is an absolute URL, check if it's the same origin
const url = new URL(referrer, this.ctx.href)
if (url.host === this.ctx.host) {
this.redirect(referrer)
return
}
}
// no referrer, use alt or '/'
this.redirect(alt || '/')
}
注意安全设计:只有 Referrer 与当前请求同 host 时才会跳回 Referrer,跨域 Referrer 一律落到 alt || '/',防止开放重定向。
response.attachment([filename], [options])
将 Content-Disposition 设为 attachment,提示客户端下载。filename 与 options 均可选,options 支持 content-disposition 包的配置项(如 type、fallback 等)。源码(lib/response.js)还有一个贴心细节:
attachment (filename, options) {
if (filename && !this.has('Content-Type')) {
this.type = extname(filename)
}
this.set('Content-Disposition', contentDisposition(filename, options))
}
传了 filename 且尚未设置 Content-Type 时,会按文件扩展名自动推断 MIME(例如 report.xlsx → application/vnd.openxmlformats-officedocument.spreadsheetml.sheet)。
lastModified 与 etag:缓存协商头
response.lastModified / response.lastModified=
getter 将 Last-Modified 头解析回 Date(不存在则 undefined);setter 接受 Date 或日期字符串,统一转成 UTC 字符串写头(lib/response.js):
ctx.response.lastModified = new Date();
ctx.response.lastModified = '2013-09-13';
response.etag=
设置带引号包裹的 ETag,自动补全缺失的引号(支持 W/ 弱校验前缀):
ctx.response.etag = crypto.createHash('md5').update(ctx.body).digest('hex');
// 最终写入: ETag: "d41d8cd98f00b204e9800998ecf8427e"
set etag (val) {
if (!/^(W\/)?"/.test(val)) val = `"${val}"`
this.set('ETag', val)
}
文档注明“没有对应的 response.etag getter”,但当前源码中实际已补充了 getter(返回 this.get('ETag'),lib/response.js)——以仓库源码为准,现在可以双向读写了。
response.vary / response.headerSent / response.flushHeaders / response.writable
- vary(field):给
Vary头追加字段,底层用vary包做去重合并(lib/response.js);headerSent后静默返回。 - headerSent:透传
this.res.headersSent,判断响应头是否已经发出。文档提示其用途:“检查客户端在出错时是否可能还来得及收到通知”——因为头一旦发出,状态码就改不了了。前面status、set、remove、vary等 setter 全部都有if (this.headerSent) return保护,这个 getter 是整个头写入安全机制的判断依据。 - flushHeaders():
res.flushHeaders()的代理,用于先把头刷给客户端再开始写 body(分块传输、SSE 等场景)。 - writable:布尔值,指示响应是否仍可写(lib/response.js)。实现综合了
res.writableEnded、res.finished与socket.writable三个信号——“已有 pending 响应时 socket 为 falsy 但依然可写”的情况被显式处理为返回true。
幕后机制:application.js 中 respond() 的完整流程
以上 API 只是“记账”,真正把响应写出去的是 lib/application.js 的 respond(ctx)。理解这个函数能解释很多 body 相关的行为:
function respond (ctx) {
// allow bypassing koa
if (ctx.respond === false) return // 允许中间件接管底层 res(如 WebSocket 升级)
const res = ctx.res
if (!ctx.writable) return res.end()
let body = ctx.body
const code = ctx.status
// ignore body
if (statuses.empty[code]) { // 204/304 等:强制去 body
ctx.body = null
return res.end()
}
if (ctx.method === 'HEAD') { // HEAD:补 Content-Length 但不发 body
if (!res.headersSent && !ctx.response.has('Content-Length')) {
const { length } = ctx.response
if (Number.isInteger(length)) ctx.length = length
}
return res.end()
}
// status body
if (body === null || body === undefined) {
if (ctx.response._explicitNullBody) { // 显式 null body:清空相关头后结束
ctx.response.remove('Content-Type')
ctx.response.remove('Transfer-Encoding')
ctx.length = 0
return res.end()
}
if (ctx.req.httpVersionMajor >= 2) {
body = String(code) // HTTP/2 返回状态码数字
} else {
body = ctx.message || String(code) // HTTP/1.x 返回标准状态消息
}
if (!res.headersSent) {
ctx.type = 'text'
ctx.length = Buffer.byteLength(body)
}
return res.end(body)
}
// responses
if (Buffer.isBuffer(body)) return res.end(body)
if (typeof body === 'string') return res.end(body)
let stream = null
if (body instanceof Blob) stream = Stream.Readable.from(body.stream())
else if (body instanceof ReadableStream) stream = Stream.Readable.from(body)
else if (body instanceof Response) stream = Stream.Readable.from(body?.body || '')
else if (isStream(body)) stream = body
if (stream) {
return Stream.pipeline(stream, res, err => {
if (err && ctx.app.listenerCount('error')) ctx.onerror(err)
})
}
// body: json
body = JSON.stringify(body)
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body)
}
res.end(body)
}
结合文档可以读出几条重要规则:
- 空状态码(204、304 等)无条件去 body:即使你设置了 body,
respond也会先执行ctx.body = null再res.end(),与statussetter 中“empty 状态强制清空 body”形成双保险; - 未显式
null的 undefined body 会退化为文本状态消息(HTTP/1.x 下 body 是 "Not Found" 之类的字符串)——这正是“默认 404”的最终呈现;而ctx.body = null(显式)走_explicitNullBody分支,返回一个长度为 0 的空响应; - stream 统一走
Stream.pipeline:相比裸pipe,pipeline 保证错误传播与自动 destroy,这也是为什么 body setter 中onFinishdestroy 之后,respond里不再重复挂 error 监听(见 tests/response/body.test.js 的 “should not add error handler to stream (handled by pipeline)”); ctx.respond = false逃逸口:需要完全绕过 Koa 响应处理(如升级 WebSocket)时,置为false后 Koa 不再碰res;- JSON 序列化在此发生,且只有头尚未发出时才补
Content-Length——所以 body 的 length 推算必须发生在ctx.flushHeaders()之前。
测试基线与延伸阅读
Response 行为的回归基线位于 __tests__/response/ 目录,与文档 API 一一对应:body.test.js(body 类型分发、Content-Type 覆盖、stream 替换)、redirect.test.js(URL 归一化、HTML 协商)、status.test.js、set.test.js、has.test.js、append.test.js、remove.test.js、type.test.js、etag.test.js、last-modified.test.js、attachment.test.js、vary.test.js、writable.test.js 等。测试基于 Node 内置 node:test 与 supertest,可用 npm test(即 node --test)运行全部用例;测试用上下文辅助器见 test-helpers/context.js。
与本文相关的其它仓库文档:Request API(ctx.request.is() 与 ctx.accepts 的对照)、Context API(ctx.onerror、ctx.assert 的实现说明)、Application API(ctx.respond = false 与应用级错误处理)。
小结
Koa 的 Response 抽象可以浓缩为三层:
- 声明层:
status/body/type/length等属性赋值,配合_explicitStatus、_explicitNullBody两个内部标志完成 200/204 自动协商与 404 默认值语义; - 头控层:
set/append/remove/has/get加headerSent保护,所有写入在头发出后静默失效,保证不会破坏已提交的状态行; - 发送层:
respond()统一处理空状态去 body、HEAD 补长、HTTP/2 状态消息、stream pipeline 与 JSON 序列化,让中间件只需关心“说什么”,而无需关心“怎么发”。
掌握这条链路后,代理透传、文件下载、缓存协商与自定义响应格式等场景的实现,都有了明确的源码依据可循。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00