Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
51 changes: 47 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,40 @@ app.get('/', (req, reply) => {
await app.listen({ port: 3000 })
```

It's also possible to pass a Fetch API `Response` object or a Web `ReadableStream`. The plugin will automatically extract the body stream from the `Response` or convert the Web stream to a Node.js `Readable` behind the scenes.

```js
import fastify from 'fastify'

const app = fastify()
await app.register(import('@fastify/compress'), { global: true })

app.get('/', async (req, reply) => {
const resp = new Response('Hello from Fetch Response')
reply.compress(resp)
})
```

```js
app.get('/', async (req, reply) => {
return new Response('Hello from Fetch Response')
})
```

```js
app.get('/', (req, reply) => {
const stream = new ReadableStream({
start (controller) {
controller.enqueue(new TextEncoder().encode('Hello from Web ReadableStream'))
controller.close()
}
})

reply.header('content-type', 'text/plain')
reply.compress(stream)
})
```

## Compress Options

### threshold
Expand Down Expand Up @@ -222,15 +256,24 @@ This plugin adds a `preParsing` hook to decompress the request payload based on

Currently, the following encoding tokens are supported:

1. `zstd` (Node.js 22.15+/23.8+)
2. `br`
3. `gzip`
4. `deflate`
- `zstd` (Node.js 22.15+/23.8+)
- `br`
- `gzip`
- `deflate`

If an unsupported encoding or invalid payload is received, the plugin throws an error.

If the request header is missing, the plugin yields to the next hook.

### Supported payload types

The plugin supports compressing the following payload types:

- Strings and Buffers
- Node.js streams
- Response objects (from the Fetch API)
- ReadableStream objects (from the Web Streams API)

### Global hook

The global request decompression hook is enabled by default. To disable it, pass `{ global: false }`:
Expand Down
19 changes: 18 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const { Minipass } = require('minipass')
const pumpify = require('pumpify')
const { Readable } = require('readable-stream')

const { isStream, isGzip, isDeflate, intoAsyncIterator } = require('./lib/utils')
const { isStream, isGzip, isDeflate, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable } = require('./lib/utils')

const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
Expand Down Expand Up @@ -254,6 +254,15 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
if (payload == null) {
return next()
}

if (isFetchResponse(payload)) {
payload = payload.body
}

if (isWebReadableStream(payload)) {
payload = webStreamToNodeReadable(payload)
}

const responseEncoding = reply.getHeader('Content-Encoding')
if (responseEncoding && responseEncoding !== 'identity') {
// response is already compressed
Expand Down Expand Up @@ -376,6 +385,14 @@ function compress (params) {
return
}

if (isFetchResponse(payload)) {
payload = payload.body
}

if (isWebReadableStream(payload)) {
payload = webStreamToNodeReadable(payload)
}

let stream, encoding
const noCompress =
// don't compress on x-no-compression header
Expand Down
16 changes: 15 additions & 1 deletion lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict'

const { Readable: NodeReadable } = require('node:stream')

// https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
function isZstd (buffer) {
return (
Expand Down Expand Up @@ -49,6 +51,18 @@ function isStream (stream) {
return stream !== null && typeof stream === 'object' && typeof stream.pipe === 'function'
}

function isWebReadableStream (obj) {
return obj instanceof ReadableStream
}

function isFetchResponse (obj) {
return obj instanceof Response
}

function webStreamToNodeReadable (webStream) {
return NodeReadable.fromWeb(webStream)
}

/**
* Provide a async iteratable for Readable.from
*/
Expand Down Expand Up @@ -90,4 +104,4 @@ async function * intoAsyncIterator (payload) {
yield payload
}

module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator }
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable }
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"url": "git+https://github.com/fastify/fastify-compress.git"
},
"tsd": {
"directory": "test/types"
"directory": "types"
},
"publishConfig": {
"access": "public"
Expand Down
49 changes: 49 additions & 0 deletions test/global-compress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const JSONStream = require('jsonstream')
const Fastify = require('fastify')
const compressPlugin = require('../index')
const { once } = require('node:events')
const { ReadableStream: WebReadableStream, Response } = globalThis

describe('When `global` is not set, it is `true` by default :', async () => {
test('it should compress Buffer data using brotli when `Accept-Encoding` request header is `br`', async (t) => {
Expand Down Expand Up @@ -265,6 +266,54 @@ describe('When `global` is not set, it is `true` by default :', async () => {
const payload = zlib.gunzipSync(response.rawPayload)
t.assert.equal(payload.toString('utf-8'), 'hello')
})

test('it should compress a Fetch API Response body', async (t) => {
t.plan(1)

const fastify = Fastify()
await fastify.register(compressPlugin, { threshold: 0 })

const body = 'hello from fetch response'
fastify.get('/fetch-resp', (_request, reply) => {
const resp = new Response(body, { headers: { 'content-type': 'text/plain' } })
reply.send(resp)
})

const response = await fastify.inject({
url: '/fetch-resp',
method: 'GET',
headers: { 'accept-encoding': 'gzip' }
})
const payload = zlib.gunzipSync(response.rawPayload)
t.assert.equal(payload.toString('utf-8'), body)
})

test('it should compress a Web ReadableStream body', async (t) => {
t.plan(1)

const fastify = Fastify()
await fastify.register(compressPlugin, { threshold: 0 })

const body = 'hello from web stream'
fastify.get('/web-stream', (_request, reply) => {
const stream = new WebReadableStream({
start (controller) {
controller.enqueue(Buffer.from(body))
controller.close()
}
})
reply.header('content-type', 'text/plain')
reply.send(stream)
})

const response = await fastify.inject({
url: '/web-stream',
method: 'GET',
headers: { 'accept-encoding': 'gzip' }
})
const payload = zlib.gunzipSync(response.rawPayload)
t.assert.equal(payload.toString('utf-8'), body)
})
})

describe('It should send compressed Stream data when `global` is `true` :', async () => {
Expand Down
30 changes: 30 additions & 0 deletions test/routes-compress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,3 +440,33 @@ test('It should avoid to trigger `onSend` hook twice', async (t) => {
})
t.assert.deepEqual(JSON.parse(zlib.brotliDecompressSync(response.rawPayload)), { hi: true })
})

test('reply.compress should handle Fetch Response', async (t) => {
t.plan(1)
const fastify = Fastify()
await fastify.register(compressPlugin, { global: true, threshold: 0 })
fastify.get('/', (_req, reply) => {
const r = new Response('from reply.compress', { headers: { 'content-type': 'text/plain' } })
reply.compress(r)
})
const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
})

test('reply.compress should handle Web ReadableStream', async (t) => {
t.plan(1)
const fastify = Fastify()
await fastify.register(compressPlugin, { global: true, threshold: 0 })
fastify.get('/', (_req, reply) => {
const stream = new ReadableStream({
start (controller) {
controller.enqueue(Buffer.from('from webstream'))
controller.close()
}
})
reply.header('content-type', 'text/plain')
reply.compress(stream)
})
const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from webstream')
})
2 changes: 1 addition & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ declare module 'fastify' {
}

interface FastifyReply {
compress(input: Stream | Input): void;
compress(input: Stream | Input | Response | ReadableStream): void;
}

export interface RouteOptions {
Expand Down
15 changes: 15 additions & 0 deletions types/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,18 @@ expectError(appThatTriggerAnError.register(fastifyCompress, {
global: true,
thisOptionDoesNotExist: 'trigger a typescript error'
}))

app.get('/ts-fetch-response', async (_request, reply) => {
const resp = new Response('ok', { headers: { 'content-type': 'text/plain' } })
expectType<void>(reply.compress(resp))
})

app.get('/ts-web-readable-stream', async (_request, reply) => {
const stream = new ReadableStream({
start (controller) {
controller.enqueue(new Uint8Array([1, 2, 3]))
controller.close()
}
})
expectType<void>(reply.compress(stream))
})