Expose a non-allocating read for pending response headers on Context
What is the feature you are proposing?
There is no public way to ask whether pending response headers exist, or to read them, without materialising a Response.
The gap
c.header() writes into the private #preparedHeaders and does not set finalized (src/context.ts:519-523; finalized flips only in the res setter, :433). So the one public flag that looks like it should answer "did anything touch the response?" doesn't.
The only public read is c.res, whose getter allocates (src/context.ts:403-407):
get res(): Response {
return (this.#res ||= createResponseInstance(null, {
headers: (this.#preparedHeaders ??= new Headers()),
}))
}And reading it is not a neutral inspection — it materialises #res, after which header() writes into #res.headers and text() loses its fast path. Both points, on main at e2740d5:
import { Hono } from './index'
it('c.header() does not set finalized', async () => {
let finalizedAfterHeader: boolean | undefined
const app = new Hono()
app.use(async (c, next) => {
c.header('x-from-middleware', 'yes')
finalizedAfterHeader = c.finalized
await next()
})
app.get('/', (c) => c.text('hi'))
const res = await app.request('/')
expect(finalizedAfterHeader).toBe(false) // passes
expect(res.headers.get('x-from-middleware')).toBe('yes') // the header did land
})
it('merely reading c.res changes the response', async () => {
const build = (observe: boolean) => {
const app = new Hono()
app.use(async (c, next) => {
if (observe) {
void c.res
}
await next()
})
app.get('/', (c) => c.text('hi'))
return app.request('/')
}
const untouched = await build(false)
const observed = await build(true)
// same route, same handler, different response:
// untouched -> 'text/plain;charset=UTF-8' (text() fast path, new Response(text))
// observed -> 'text/plain; charset=UTF-8' (#newResponse)
expect(observed.headers.get('content-type')).not.toBe(untouched.headers.get('content-type'))
})That content-type difference is not what I'm reporting — it is just the cheapest proof that observing costs more than an allocation.
Hono already needs this predicate
text() at :704:
return !this.#preparedHeaders && !this.#status && !arg && !headers && !this.finalizedThe check exists — it's just private.
Why it matters outside the core too
Code that wraps Hono and builds its own Response at the end of the chain has to know whether a middleware left headers behind. With no public read there are two options: pay for c.res on every request, or track it yourself. I maintain a framework that does the latter — a Symbol() marker set after every wrapped middleware runs, plus a setter, a getter and a branch: four pieces of machinery standing in for one boolean, and it still can't avoid the allocation on the requests that do have headers.
Possible shapes
get preparedHeaders(): Headers | undefined— answers both existence and contents, allocates nothing. Downside: hands out the liveHeaders, so a caller could mutate it. Returning a copy would allocate, which defeats half the point.get hasPreparedHeaders(): boolean— minimal and leak-free, but a caller that then wants the values is back toc.res.
Both are ~40 bytes on the minified ESM bundle, measured against main at e2740d5 with perf-measures/bundle-check: 18134 B baseline, 18171 B with preparedHeaders, 18183 B with hasPreparedHeaders. Neither adds anything to an existing code path — and the boolean is the larger of the two, so bundle size isn't a reason to prefer it.
I'd lean to the first, and I have no stake in the naming.
Source: honojs/hono