Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ yarn.lock
# editor files
.vscode
.idea
.zed

#tap files
.tap/
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,12 @@ await fastify.register(
)
```

## Gotchas

When you, or another plugin modify the request body, it's possible that `@fastify/compress` will recieve a response body that it doesn't know what to do with. If this happens when you call the `compress` function directly, it'll make a best effort at compressing the payload anyway, by using the fastify `serialize` function on whatever is passed.

If the response is being compressed by the global hook, and it inadvertedly receives something it doesn't know what to do with, it'll ignore it completely and respond with the uncompressed payload. This to prevent inadvertedly breaking whole servers with hard to find bugs.

## Acknowledgments

Past sponsors:
Expand Down
18 changes: 17 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ function processCompressParams (opts) {
.sort((a, b) => opts.encodings.indexOf(a) - opts.encodings.indexOf(b))
: supportedEncodings

params.isCompressiblePayload = typeof opts.isCompressiblePayload === 'function'
? opts.isCompressiblePayload
: isCompressiblePayload

return params
}

Expand Down Expand Up @@ -273,6 +277,11 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
}

if (typeof payload.pipe !== 'function') {
// Payload is not a stream, ensure we don't try to compress something we cannot get the length of.
if (!params.isCompressiblePayload(payload)) {
return next(null, payload)
}

if (Buffer.byteLength(payload) < params.threshold) {
return next()
}
Expand Down Expand Up @@ -391,7 +400,7 @@ function compress (params) {
}

if (typeof payload.pipe !== 'function') {
if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
if (!params.isCompressiblePayload(payload)) {
payload = this.serialize(payload)
}
}
Expand Down Expand Up @@ -477,6 +486,13 @@ function getEncodingHeader (encodings, request) {
}
}

function isCompressiblePayload (payload) {
// By the time payloads reach this point, Fastify has already serialized
// objects/arrays/etc to strings, so we only need to check for the actual
// types that make it through: Buffer and string
return Buffer.isBuffer(payload) || typeof payload === 'string'
}

function shouldCompress (type, compressibleTypes) {
if (compressibleTypes(type)) return true
const data = mimedb[type.split(';', 1)[0].trim().toLowerCase()]
Expand Down
124 changes: 124 additions & 0 deletions test/global-compress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3298,3 +3298,127 @@ for (const contentType of notByDefaultSupportedContentTypes) {
t.assert.equal(response.rawPayload.toString('utf-8'), file)
})
}

test('It should not compress non-buffer/non-string payloads', async (t) => {
t.plan(4)

let payloadTypeChecked = null
let payloadReceived = null
const testIsCompressiblePayload = (payload) => {
payloadTypeChecked = typeof payload
payloadReceived = payload
// Return false for objects, true for strings/buffers like the original
return Buffer.isBuffer(payload) || typeof payload === 'string'
}

const fastify = Fastify()
await fastify.register(compressPlugin, {
isCompressiblePayload: testIsCompressiblePayload
})

// Create a Response-like object that might come from another plugin
const responseObject = new Response('{"message": "test"}', {
status: 200,
headers: { 'content-type': 'application/json' }
})

fastify.get('/', (_request, reply) => {
// Simulate a scenario where another plugin sets a Response object as the payload
// We use an onSend hook to intercept and replace the payload before compression to simulate that behavior
reply.header('content-type', 'application/json')
reply.send('{"message": "test"}')
})

// Add the onSend hook that replaces the payload with a Response object
fastify.addHook('onSend', async () => {
return responseObject
})

const response = await fastify.inject({
url: '/',
method: 'GET',
headers: {
'accept-encoding': 'gzip, deflate, br'
}
})

t.assert.equal(response.statusCode, 200)
// The response should not be compressed since the payload is a Response object
t.assert.equal(response.headers['content-encoding'], undefined)
// Verify that the payload was a Response object when isCompressiblePayload was called
t.assert.equal(payloadTypeChecked, 'object')
t.assert.equal(payloadReceived instanceof Response, true)
})

test('It should serialize and compress objects when reply.compress() receives non-compressible objects', async (t) => {
t.plan(2)

const fastify = Fastify()
await fastify.register(compressPlugin, {
threshold: 0 // Ensure even small payloads get compressed
})

// Create a larger object to ensure it exceeds any default threshold
const objectPayload = {
message: 'test data'.repeat(100),
value: 42,
description: 'A test object that should be large enough to trigger compression after serialization'.repeat(10)
}

fastify.get('/', (_request, reply) => {
reply.header('content-type', 'application/json')
// The compress function should now serialize the object and then compress it
reply.compress(objectPayload)
})

const response = await fastify.inject({
url: '/',
method: 'GET',
headers: {
'accept-encoding': 'gzip, deflate, br'
}
})

t.assert.equal(response.statusCode, 200)
// The response should be compressed since the object gets serialized to a string
t.assert.ok(['gzip', 'deflate', 'br'].includes(response.headers['content-encoding']))
})

test('It should handle Response objects by serializing them to JSON when using reply.compress()', async (t) => {
t.plan(4)

const fastify = Fastify()
await fastify.register(compressPlugin, {
threshold: 0 // Ensure even small payloads get compressed
})

// Response objects serialize to "{}" in JSON
const testContent = 'test content for compression'
const responseObject = new Response(testContent)
const directSerialized = JSON.stringify(responseObject)

fastify.get('/', (_request, reply) => {
reply.header('content-type', 'application/json')
// Response objects get serialized to "{}" by JSON.stringify
reply.compress(responseObject)
})

const response = await fastify.inject({
url: '/',
method: 'GET',
headers: {
'accept-encoding': 'gzip'
}
})

t.assert.equal(response.statusCode, 200)
// The response gets compressed because "{}" is valid JSON content
t.assert.equal(response.headers['content-encoding'], 'gzip')
// Confirm that JSON.stringify(Response) returns "{}" - the empty object
t.assert.equal(directSerialized, '{}')

// Decompress the response to verify the content is the serialized Response
const compressedBuffer = Buffer.from(response.rawPayload)
const decompressed = zlib.gunzipSync(compressedBuffer).toString('utf8')
t.assert.equal(decompressed, '{}')
})