Skip to content

Commit 6585e6c

Browse files
authored
fix: compress JSON/buffer bodies in beforeResponse (#17)
`compress` only handled string bodies, so `/server/api` responses (plain objects) and SWR/ISR cached routes were never compressed via the `beforeResponse` hook. - compress string, Buffer/Uint8Array/ArrayBuffer and JSON (object) bodies; objects are serialized as `application/json` like h3 does, streams/empty are skipped - document the `beforeResponse` hook for SWR/ISR and `/server/api` - tests on both the h3 v1 app hook and the mutable path Closes #8 Closes #5
1 parent 7b492a8 commit 6585e6c

4 files changed

Lines changed: 133 additions & 10 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,32 @@ export default defineNitroPlugin((nitro) => {
115115
> [!NOTE]
116116
> `useCompressionStream` doesn't work right now in nitro. So you just can use `useCompression`
117117
118+
### Cached routes (SWR / ISR) and `/server/api`
119+
120+
The `render:response` hook only runs for freshly rendered SSR pages. Responses served
121+
from the Nitro route cache (`routeRules` with `swr` / `isr`) and `/server/api` handlers
122+
go through the `beforeResponse` hook instead. Use it to compress those too:
123+
124+
`server/plugins/compression.ts`
125+
````ts
126+
import { useCompression } from 'h3-compression'
127+
128+
export default defineNitroPlugin((nitro) => {
129+
nitro.hooks.hook('beforeResponse', async (event, response) => {
130+
// Skip internal nuxt routes (e.g. error page)
131+
if (['/_nuxt', '/__nuxt'].some(prefix => event.path.startsWith(prefix)))
132+
return
133+
134+
await useCompression(event, response)
135+
})
136+
})
137+
````
138+
139+
`useCompression` compresses string, `Buffer`/`Uint8Array` and JSON (object) bodies and
140+
skips everything else (e.g. streams), so binary assets are left untouched. If you only
141+
want to compress specific content types, guard on `response.headers?.['content-type']`
142+
before calling it.
143+
118144
## Utilities
119145

120146
H3-compression has a concept of composable utilities that accept `event` (from `eventHandler((event) => {})`) as their first argument and `response` as their second.

src/helper.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,21 +62,62 @@ export function getStreamCompression(event: H3Event): StreamCompression | undefi
6262
return undefined
6363
}
6464

65+
function isReadableStream(value: unknown): boolean {
66+
return typeof value === 'object' && value !== null
67+
&& typeof (value as ReadableStream).getReader === 'function'
68+
}
69+
70+
/**
71+
* Turns a response body into a buffer that can be compressed. Strings, buffers
72+
* and typed arrays are used as-is; plain JSON-serializable values (e.g. objects
73+
* returned from a `/server/api` route) are serialized to JSON. Returns
74+
* `undefined` for bodies that can't / shouldn't be buffered (streams, empty).
75+
*/
76+
function toCompressibleBuffer(event: H3Event, body: unknown): Buffer | undefined {
77+
if (typeof body === 'string')
78+
return Buffer.from(body)
79+
80+
if (body instanceof Uint8Array)
81+
return Buffer.from(body)
82+
83+
if (body instanceof ArrayBuffer)
84+
return Buffer.from(body)
85+
86+
if (body === null || body === undefined || isReadableStream(body))
87+
return undefined
88+
89+
try {
90+
const json = JSON.stringify(body)
91+
if (json === undefined)
92+
return undefined
93+
// Mirror h3, which serializes objects as JSON.
94+
setResponseHeader(event, 'Content-Type', 'application/json')
95+
return Buffer.from(json)
96+
}
97+
catch {
98+
return undefined
99+
}
100+
}
101+
65102
export async function compress(event: H3Event, response: Partial<RenderResponse>, method: Compression) {
66-
const compression = promisify(zlib[method === 'br' ? 'brotliCompress' : method])
67103
const acceptsEncoding = getRequestHeader(event, 'accept-encoding')?.includes(
68104
method,
69105
)
106+
if (!acceptsEncoding)
107+
return
70108

71-
if (acceptsEncoding && typeof response.body === 'string') {
72-
setResponseHeader(event, 'Content-Encoding', method)
73-
const compressed = await compression(Buffer.from(response.body))
74-
// h3 v1 streams the body via `send`, h3 v2 expects the (mutated) body.
75-
if (typeof send === 'function')
76-
send(event, compressed)
77-
else
78-
response.body = compressed
79-
}
109+
const payload = toCompressibleBuffer(event, response.body)
110+
if (!payload)
111+
return
112+
113+
const compression = promisify(zlib[method === 'br' ? 'brotliCompress' : method])
114+
setResponseHeader(event, 'Content-Encoding', method)
115+
const compressed = await compression(payload)
116+
// h3 v1 streams the body via `send`, h3 v2 expects the (mutated) body.
117+
if (typeof send === 'function')
118+
send(event, compressed)
119+
else
120+
response.body = compressed
80121
}
81122

82123
export async function compressStream(event: H3Event, response: Partial<RenderResponse>, method: StreamCompression) {

test/compression-v1.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,38 @@
1+
import { Buffer } from 'node:buffer'
2+
import zlib from 'node:zlib'
13
import type { SuperTest, Test } from 'supertest'
24
import supertest from 'supertest'
35
import { beforeEach, describe, expect, it } from 'vitest'
46
import * as h3 from 'h3'
57
import { useCompression, useCompressionStream } from '../src'
68
import { isV1 } from './_version'
79

10+
// superagent does not auto-decode brotli, so read the raw bytes ourselves.
11+
function rawParser(res: any, cb: (err: Error | null, body: Buffer) => void) {
12+
const chunks: Buffer[] = []
13+
res.on('data', (c: Buffer) => chunks.push(c))
14+
res.on('end', () => cb(null, Buffer.concat(chunks)))
15+
}
16+
817
// `createApp` / `eventHandler` / `toNodeListener` exist in both h3 versions, but
918
// the `onBeforeResponse` app hook only works in v1.
1019
const { createApp, eventHandler, toNodeListener } = h3 as typeof import('h3')
1120

1221
const html = '<h1>Hello World</h1>'
22+
const json = { message: 'hello world', items: [1, 2, 3, 4, 5] }
1323

1424
function appWith(hook: typeof useCompression) {
1525
const app = createApp({ debug: true, onBeforeResponse: hook })
1626
app.use('/', eventHandler(() => html))
1727
return supertest(toNodeListener(app))
1828
}
1929

30+
function jsonAppWith(hook: typeof useCompression) {
31+
const app = createApp({ debug: true, onBeforeResponse: hook })
32+
app.use('/api', eventHandler(() => json))
33+
return supertest(toNodeListener(app))
34+
}
35+
2036
describe.runIf(isV1)('useCompression (h3 v1 app hook)', () => {
2137
let request: SuperTest<Test>
2238

@@ -48,6 +64,34 @@ describe.runIf(isV1)('useCompression (h3 v1 app hook)', () => {
4864
})
4965
})
5066

67+
describe.runIf(isV1)('useCompression with JSON body (h3 v1 app hook)', () => {
68+
// Regression test for #8: /server/api JSON responses are objects in the
69+
// `beforeResponse` hook, not strings, and were not compressed.
70+
it('compresses an object (JSON) body with gzip', async () => {
71+
const request = jsonAppWith(useCompression)
72+
const result = await request.get('/api').set('Accept-Encoding', 'gzip')
73+
74+
expect(result.status).toEqual(200)
75+
expect(result.headers['content-encoding']).toEqual('gzip')
76+
expect(result.headers['content-type']).toContain('application/json')
77+
expect(result.body).toEqual(json)
78+
})
79+
80+
it('compresses an object (JSON) body with brotli', async () => {
81+
const request = jsonAppWith(useCompression)
82+
const result = await request
83+
.get('/api')
84+
.set('Accept-Encoding', 'br')
85+
.buffer(true)
86+
.parse(rawParser)
87+
88+
expect(result.status).toEqual(200)
89+
expect(result.headers['content-encoding']).toEqual('br')
90+
expect(result.headers['content-type']).toContain('application/json')
91+
expect(JSON.parse(zlib.brotliDecompressSync(result.body).toString())).toEqual(json)
92+
})
93+
})
94+
5195
describe.runIf(isV1)('useCompressionStream (h3 v1 app hook)', () => {
5296
let request: SuperTest<Test>
5397

test/compression.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ describe.runIf(isV2)('useCompression (mutable response / nitro path)', () => {
3737
expect(decoders.gzip(response.body as Buffer).toString()).toEqual(html)
3838
})
3939

40+
it('compresses an object (JSON) body and sets the content-type (#8)', async () => {
41+
const event = eventFor('gzip')
42+
const json = { message: 'hello world', items: [1, 2, 3] }
43+
const response: { body: unknown } = { body: json }
44+
45+
await useGZipCompression(event, response)
46+
47+
expect(event.res.headers.get('content-encoding')).toEqual('gzip')
48+
expect(event.res.headers.get('content-type')).toContain('application/json')
49+
expect(JSON.parse(decoders.gzip(response.body as Buffer).toString())).toEqual(json)
50+
})
51+
4052
it('compresses the body with deflate', async () => {
4153
const event = eventFor('deflate')
4254
const response = { body: html }

0 commit comments

Comments
 (0)