Summary
compress() and compressStream() in src/helper.ts do not check whether the response already carries a Content-Encoding, and they never adjust the now-stale Content-Length. When they run over an already-encoded response, the result is a doubly-compressed body with a wrong Content-Length.
This is inconsistent with the Web-API path in the same file: compressResponse() / compressResponseStream() correctly bail out on an existing Content-Encoding, and cloneResponse() deletes Content-Length.
|
guards Content-Encoding |
fixes Content-Length |
compressResponse() (helper.ts#L323) |
✅ |
✅ (cloneResponse helper.ts#L295) |
compressResponseStream() (helper.ts#L354) |
✅ |
✅ |
compress() (helper.ts#L229) |
❌ |
❌ |
compressStream() (helper.ts#L255) |
❌ |
❌ |
compress() / compressStream() are exactly the functions the Nuxt module's onBeforeResponse hook ends up calling, so this is the path most users hit.
Impact
This bit us in production on a Nuxt 4 app. With nitro.compressPublicAssets: true (a very common setting), Nitro's static handler serves the pre-built .br file and sets Content-Encoding: br + Content-Length itself. h3-compression then compresses that again on beforeResponse.
The consequences:
- The browser gets unusable assets. It decodes once per
Content-Encoding: br and is left with brotli binary instead of CSS. Every stylesheet served from public/ silently failed to apply — the entire site rendered unstyled.
- It's an HTTP framing violation. The announced
Content-Length no longer matches the bytes on the wire. Node's own HTTP client aborts the connection with HPE_INVALID_CONSTANT: Parse Error: Expected HTTP/ because it reads past the response boundary into what it thinks is the next one. On keep-alive connections this desyncs the stream.
- It is very hard to diagnose. It cannot reproduce in dev (no compression there), and typical E2E tests don't catch it because the DOM is fully correct — only the rendering is broken.
curl and fetch() also hide it, since both transparently decode one layer.
DEFAULT_EXCLUDE = ['/_nuxt', '/__nuxt'] covers Nuxt's own build output, but not project-specific folders under public/ (/css, /fonts, /images, …), which is where this surfaced for us.
Reproduction
Self-contained, no Nuxt needed. It reads the raw socket on purpose — fetch/curl/undici transparently decode one layer and would hide the bug.
import { createApp, defineEventHandler, toNodeListener, setResponseHeader } from 'h3'
import { useBrotliCompression } from 'h3-compression'
import { createServer } from 'node:http'
import { connect } from 'node:net'
import { brotliCompressSync, brotliDecompressSync } from 'node:zlib'
const ORIGINAL = '/* hello */ ' + 'a'.repeat(5000)
const PRECOMPRESSED = brotliCompressSync(Buffer.from(ORIGINAL))
const app = createApp({
onBeforeResponse: async (event, response) => {
await useBrotliCompression(event, response)
},
})
// Exactly what Nitro's static handler does when `compressPublicAssets` is on:
// serve the pre-built `.br` file and set the headers itself.
app.use('/asset.css', defineEventHandler((event) => {
setResponseHeader(event, 'Content-Type', 'text/css')
setResponseHeader(event, 'Content-Encoding', 'br')
setResponseHeader(event, 'Content-Length', String(PRECOMPRESSED.byteLength))
return PRECOMPRESSED
}))
const server = createServer(toNodeListener(app))
await new Promise(r => server.listen(3999, r))
const raw = await new Promise((resolve, reject) => {
const sock = connect(3999, '127.0.0.1', () => {
sock.write('GET /asset.css HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: br\r\nConnection: close\r\n\r\n')
})
const chunks = []
sock.on('data', c => chunks.push(c))
sock.on('end', () => resolve(Buffer.concat(chunks)))
sock.on('error', reject)
})
const sep = raw.indexOf('\r\n\r\n')
const head = raw.subarray(0, sep).toString()
const body = raw.subarray(sep + 4)
console.log('Content-Length says :', /content-length: (\d+)/i.exec(head)[1])
console.log('bytes actually sent :', body.byteLength)
console.log('decompress 1x ->', brotliDecompressSync(body).byteLength, 'bytes')
console.log('decompress 2x ->', brotliDecompressSync(brotliDecompressSync(body)).byteLength, 'bytes')
server.close()
Actual output (h3-compression 1.1.0, h3 1.15.11, Node 22)
Content-Length says : 22
bytes actually sent : 26 <-- mismatch
decompress 1x -> 22 bytes
decompress 2x -> 5012 bytes <-- the original payload
Decoding twice returns the original byte-for-byte, which confirms the body was compressed twice.
Expected
The response passes through untouched: Content-Length: 22, 22 bytes on the wire, and a single decode yields the original 5012 bytes.
Suggested fix
In compress() and compressStream(), mirror what the Web-API path already does:
- Bail out when the response is already encoded, next to the existing
accept-encoding check:
if (getResponseHeader(event, 'Content-Encoding'))
return
- Drop the stale
Content-Length when setting Content-Encoding (or set it to the compressed byte length for the non-stream path):
setResponseHeader(event, 'Content-Encoding', method)
removeResponseHeader(event, 'Content-Length')
Point 2 is worth fixing regardless of point 1, since it's a protocol-level violation on its own.
Happy to open a PR if that's useful.
Environment
h3-compression 1.1.0
h3 1.15.11
- Nuxt 4.5.2 / Nitro with
compressPublicAssets: true
- Node 22
Summary
compress()andcompressStream()insrc/helper.tsdo not check whether the response already carries aContent-Encoding, and they never adjust the now-staleContent-Length. When they run over an already-encoded response, the result is a doubly-compressed body with a wrongContent-Length.This is inconsistent with the Web-API path in the same file:
compressResponse()/compressResponseStream()correctly bail out on an existingContent-Encoding, andcloneResponse()deletesContent-Length.Content-EncodingContent-LengthcompressResponse()(helper.ts#L323)cloneResponsehelper.ts#L295)compressResponseStream()(helper.ts#L354)compress()(helper.ts#L229)compressStream()(helper.ts#L255)compress()/compressStream()are exactly the functions the Nuxt module'sonBeforeResponsehook ends up calling, so this is the path most users hit.Impact
This bit us in production on a Nuxt 4 app. With
nitro.compressPublicAssets: true(a very common setting), Nitro's static handler serves the pre-built.brfile and setsContent-Encoding: br+Content-Lengthitself.h3-compressionthen compresses that again onbeforeResponse.The consequences:
Content-Encoding: brand is left with brotli binary instead of CSS. Every stylesheet served frompublic/silently failed to apply — the entire site rendered unstyled.Content-Lengthno longer matches the bytes on the wire. Node's own HTTP client aborts the connection withHPE_INVALID_CONSTANT: Parse Error: Expected HTTP/because it reads past the response boundary into what it thinks is the next one. On keep-alive connections this desyncs the stream.curlandfetch()also hide it, since both transparently decode one layer.DEFAULT_EXCLUDE = ['/_nuxt', '/__nuxt']covers Nuxt's own build output, but not project-specific folders underpublic/(/css,/fonts,/images, …), which is where this surfaced for us.Reproduction
Self-contained, no Nuxt needed. It reads the raw socket on purpose —
fetch/curl/undici transparently decode one layer and would hide the bug.Actual output (h3-compression 1.1.0, h3 1.15.11, Node 22)
Decoding twice returns the original byte-for-byte, which confirms the body was compressed twice.
Expected
The response passes through untouched:
Content-Length: 22, 22 bytes on the wire, and a single decode yields the original 5012 bytes.Suggested fix
In
compress()andcompressStream(), mirror what the Web-API path already does:accept-encodingcheck:Content-Lengthwhen settingContent-Encoding(or set it to the compressed byte length for the non-stream path):Point 2 is worth fixing regardless of point 1, since it's a protocol-level violation on its own.
Happy to open a PR if that's useful.
Environment
h3-compression1.1.0h31.15.11compressPublicAssets: true