Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 51 additions & 10 deletions src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RenderResponse>, 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<RenderResponse>, method: StreamCompression) {
Expand Down
44 changes: 44 additions & 0 deletions test/compression-v1.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
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'
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 = '<h1>Hello World</h1>'
const json = { message: 'hello world', items: [1, 2, 3, 4, 5] }

function appWith(hook: typeof useCompression) {
const app = createApp({ debug: true, onBeforeResponse: hook })

Check warning on line 25 in test/compression-v1.test.ts

View workflow job for this annotation

GitHub Actions / lint

There is more than one component in this file
app.use('/', eventHandler(() => html))
return supertest(toNodeListener(app))
}

function jsonAppWith(hook: typeof useCompression) {
const app = createApp({ debug: true, onBeforeResponse: hook })

Check warning on line 31 in test/compression-v1.test.ts

View workflow job for this annotation

GitHub Actions / lint

There is more than one component in this file
app.use('/api', eventHandler(() => json))
return supertest(toNodeListener(app))
}

describe.runIf(isV1)('useCompression (h3 v1 app hook)', () => {
let request: SuperTest<Test>

Expand Down Expand Up @@ -48,6 +64,34 @@
})
})

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<Test>

Expand Down
12 changes: 12 additions & 0 deletions test/compression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading