From 6bdaaaf27c719228e7c568429019e1b57112b2de Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Tue, 9 Jun 2026 11:58:45 +0200 Subject: [PATCH] fix: compress JSON/buffer bodies and document beforeResponse usage `compress` only handled string bodies, so `/server/api` responses (plain objects) and other non-string bodies were never compressed via the `beforeResponse` hook. - compress string, Buffer/Uint8Array/ArrayBuffer and JSON (object) bodies; objects are serialized and tagged as `application/json` like h3 does, while streams/empty bodies are skipped - document the `beforeResponse` hook for SWR/ISR cached routes and `/server/api` - tests for object bodies on both the h3 v1 app hook and the mutable path Closes #8 Closes #5 Co-Authored-By: Claude Opus 4.8 --- README.md | 26 ++++++++++++++++ src/helper.ts | 61 +++++++++++++++++++++++++++++++------ test/compression-v1.test.ts | 44 ++++++++++++++++++++++++++ test/compression.test.ts | 12 ++++++++ 4 files changed, 133 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fbb7b91..49d9054 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,32 @@ export default defineNitroPlugin((nitro) => { > [!NOTE] > `useCompressionStream` doesn't work right now in nitro. So you just can use `useCompression` +### Cached routes (SWR / ISR) and `/server/api` + +The `render:response` hook only runs for freshly rendered SSR pages. Responses served +from the Nitro route cache (`routeRules` with `swr` / `isr`) and `/server/api` handlers +go through the `beforeResponse` hook instead. Use it to compress those too: + +`server/plugins/compression.ts` +````ts +import { useCompression } from 'h3-compression' + +export default defineNitroPlugin((nitro) => { + nitro.hooks.hook('beforeResponse', async (event, response) => { + // Skip internal nuxt routes (e.g. error page) + if (['/_nuxt', '/__nuxt'].some(prefix => event.path.startsWith(prefix))) + return + + await useCompression(event, response) + }) +}) +```` + +`useCompression` compresses string, `Buffer`/`Uint8Array` and JSON (object) bodies and +skips everything else (e.g. streams), so binary assets are left untouched. If you only +want to compress specific content types, guard on `response.headers?.['content-type']` +before calling it. + ## Utilities H3-compression has a concept of composable utilities that accept `event` (from `eventHandler((event) => {})`) as their first argument and `response` as their second. diff --git a/src/helper.ts b/src/helper.ts index fe50ca3..5147523 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -62,21 +62,62 @@ export function getStreamCompression(event: H3Event): StreamCompression | undefi return undefined } +function isReadableStream(value: unknown): boolean { + return typeof value === 'object' && value !== null + && typeof (value as ReadableStream).getReader === 'function' +} + +/** + * Turns a response body into a buffer that can be compressed. Strings, buffers + * and typed arrays are used as-is; plain JSON-serializable values (e.g. objects + * returned from a `/server/api` route) are serialized to JSON. Returns + * `undefined` for bodies that can't / shouldn't be buffered (streams, empty). + */ +function toCompressibleBuffer(event: H3Event, body: unknown): Buffer | undefined { + if (typeof body === 'string') + return Buffer.from(body) + + if (body instanceof Uint8Array) + return Buffer.from(body) + + if (body instanceof ArrayBuffer) + return Buffer.from(body) + + if (body === null || body === undefined || isReadableStream(body)) + return undefined + + try { + const json = JSON.stringify(body) + if (json === undefined) + return undefined + // Mirror h3, which serializes objects as JSON. + setResponseHeader(event, 'Content-Type', 'application/json') + return Buffer.from(json) + } + catch { + return undefined + } +} + export async function compress(event: H3Event, response: Partial, method: Compression) { - const compression = promisify(zlib[method === 'br' ? 'brotliCompress' : method]) const acceptsEncoding = getRequestHeader(event, 'accept-encoding')?.includes( method, ) + if (!acceptsEncoding) + return - if (acceptsEncoding && typeof response.body === 'string') { - setResponseHeader(event, 'Content-Encoding', method) - const compressed = await compression(Buffer.from(response.body)) - // h3 v1 streams the body via `send`, h3 v2 expects the (mutated) body. - if (typeof send === 'function') - send(event, compressed) - else - response.body = compressed - } + const payload = toCompressibleBuffer(event, response.body) + if (!payload) + return + + const compression = promisify(zlib[method === 'br' ? 'brotliCompress' : method]) + setResponseHeader(event, 'Content-Encoding', method) + const compressed = await compression(payload) + // h3 v1 streams the body via `send`, h3 v2 expects the (mutated) body. + if (typeof send === 'function') + send(event, compressed) + else + response.body = compressed } export async function compressStream(event: H3Event, response: Partial, method: StreamCompression) { diff --git a/test/compression-v1.test.ts b/test/compression-v1.test.ts index 4ea82f5..d8010ff 100644 --- a/test/compression-v1.test.ts +++ b/test/compression-v1.test.ts @@ -1,3 +1,5 @@ +import { Buffer } from 'node:buffer' +import zlib from 'node:zlib' import type { SuperTest, Test } from 'supertest' import supertest from 'supertest' import { beforeEach, describe, expect, it } from 'vitest' @@ -5,11 +7,19 @@ import * as h3 from 'h3' import { useCompression, useCompressionStream } from '../src' import { isV1 } from './_version' +// superagent does not auto-decode brotli, so read the raw bytes ourselves. +function rawParser(res: any, cb: (err: Error | null, body: Buffer) => void) { + const chunks: Buffer[] = [] + res.on('data', (c: Buffer) => chunks.push(c)) + res.on('end', () => cb(null, Buffer.concat(chunks))) +} + // `createApp` / `eventHandler` / `toNodeListener` exist in both h3 versions, but // the `onBeforeResponse` app hook only works in v1. const { createApp, eventHandler, toNodeListener } = h3 as typeof import('h3') const html = '

Hello World

' +const json = { message: 'hello world', items: [1, 2, 3, 4, 5] } function appWith(hook: typeof useCompression) { const app = createApp({ debug: true, onBeforeResponse: hook }) @@ -17,6 +27,12 @@ function appWith(hook: typeof useCompression) { return supertest(toNodeListener(app)) } +function jsonAppWith(hook: typeof useCompression) { + const app = createApp({ debug: true, onBeforeResponse: hook }) + app.use('/api', eventHandler(() => json)) + return supertest(toNodeListener(app)) +} + describe.runIf(isV1)('useCompression (h3 v1 app hook)', () => { let request: SuperTest @@ -48,6 +64,34 @@ describe.runIf(isV1)('useCompression (h3 v1 app hook)', () => { }) }) +describe.runIf(isV1)('useCompression with JSON body (h3 v1 app hook)', () => { + // Regression test for #8: /server/api JSON responses are objects in the + // `beforeResponse` hook, not strings, and were not compressed. + it('compresses an object (JSON) body with gzip', async () => { + const request = jsonAppWith(useCompression) + const result = await request.get('/api').set('Accept-Encoding', 'gzip') + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('gzip') + expect(result.headers['content-type']).toContain('application/json') + expect(result.body).toEqual(json) + }) + + it('compresses an object (JSON) body with brotli', async () => { + const request = jsonAppWith(useCompression) + const result = await request + .get('/api') + .set('Accept-Encoding', 'br') + .buffer(true) + .parse(rawParser) + + expect(result.status).toEqual(200) + expect(result.headers['content-encoding']).toEqual('br') + expect(result.headers['content-type']).toContain('application/json') + expect(JSON.parse(zlib.brotliDecompressSync(result.body).toString())).toEqual(json) + }) +}) + describe.runIf(isV1)('useCompressionStream (h3 v1 app hook)', () => { let request: SuperTest diff --git a/test/compression.test.ts b/test/compression.test.ts index 2e22fe9..36ae316 100644 --- a/test/compression.test.ts +++ b/test/compression.test.ts @@ -37,6 +37,18 @@ describe.runIf(isV2)('useCompression (mutable response / nitro path)', () => { expect(decoders.gzip(response.body as Buffer).toString()).toEqual(html) }) + it('compresses an object (JSON) body and sets the content-type (#8)', async () => { + const event = eventFor('gzip') + const json = { message: 'hello world', items: [1, 2, 3] } + const response: { body: unknown } = { body: json } + + await useGZipCompression(event, response) + + expect(event.res.headers.get('content-encoding')).toEqual('gzip') + expect(event.res.headers.get('content-type')).toContain('application/json') + expect(JSON.parse(decoders.gzip(response.body as Buffer).toString())).toEqual(json) + }) + it('compresses the body with deflate', async () => { const event = eventFor('deflate') const response = { body: html }