From 51305cc625f0c817e2d1882e484e49fc54079bd4 Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Tue, 17 Jun 2025 14:24:22 +0900 Subject: [PATCH 1/6] fix: prevents fastify-compress from trying to work with something it cannot Sending something other than buffers of strings in the `payload` happens, but this would break the later `Buffer.byteLength(payload)` Signed-off-by: Bart Riepe --- .gitignore | 1 + index.js | 18 ++++- test/global-compress.test.js | 124 +++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2b6aed4..e4083aa 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,7 @@ yarn.lock # editor files .vscode .idea +.zed #tap files .tap/ diff --git a/index.js b/index.js index 0aabf9b..b545173 100644 --- a/index.js +++ b/index.js @@ -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 } @@ -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() } @@ -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) } } @@ -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()] diff --git a/test/global-compress.test.js b/test/global-compress.test.js index 4c142bc..fcfe73e 100644 --- a/test/global-compress.test.js +++ b/test/global-compress.test.js @@ -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, '{}') +}) From 64f3a8dc6d046481dbd42936e4fae271493cf9e9 Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Thu, 19 Jun 2025 08:55:12 +0900 Subject: [PATCH 2/6] chore: add description of 'compression failure' to the README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 7953282..2b8be00 100644 --- a/README.md +++ b/README.md @@ -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: From 73ed79fff56bc9c6bc879a765f64a9aaf8dca59d Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Thu, 19 Jun 2025 10:34:30 +0900 Subject: [PATCH 3/6] feat: add support for compressing Response objects from Fetch API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Response object detection in isCompressiblePayload - Implement convertResponseToStream helper to extract body from Response objects - Handle Response headers and status code preservation in both onSend hook and reply.compress() - Add comprehensive tests for Response object compression scenarios Closes #309 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .gitignore | 3 + README.md | 40 +++++++- index.js | 53 ++++++++-- test/global-compress.test.js | 188 +++++++++++++++++++++++++++++++++-- types/index.d.ts | 2 +- 5 files changed, 265 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index e4083aa..c117a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,6 @@ yarn.lock #tap files .tap/ + +# Claude AI +.claude/ diff --git a/README.md b/README.md index 2b8be00..7ffc673 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ To return an error for unsupported encoding, use the `onUnsupportedEncoding` opt The plugin compresses payloads based on `content-type`. If absent, it assumes `application/json`. +### 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 compression hook is enabled by default. To disable it, pass `{ global: false }`: ```js @@ -87,6 +95,8 @@ fastify.get('/custom-route', { ### `reply.compress` This plugin adds a `compress` method to `reply` that compresses a stream or string based on the `accept-encoding` header. If a JS object is passed, it will be stringified to JSON. +> ℹ️ Note: When compressing a Response object, the compress middleware only extracts and compresses the body stream. It will handle compression-related headers (like `Content-Encoding` and `Vary`) but does not copy other headers or status from the Response object - these remain the responsibility of your application or Fastify's built-in handling. + The `compress` method uses per-route parameters if configured, otherwise it uses global parameters. ```js @@ -96,12 +106,29 @@ import fastify from 'fastify' const app = fastify() await app.register(import('@fastify/compress'), { global: false }) -app.get('/', (req, reply) => { +// Compress a file stream +app.get('/file', (req, reply) => { reply .type('text/plain') .compress(fs.createReadStream('./package.json')) }) +// Compress a Response object from fetch +app.get('/fetch', async (req, reply) => { + const response = await fetch('https://api.example.com/data') + reply + .type('application/json') + .compress(response) +}) + +// Compress a ReadableStream +app.get('/stream', (req, reply) => { + const response = new Response('Hello World') + reply + .type('text/plain') + .compress(response.body) +}) + await app.listen({ port: 3000 }) ``` @@ -109,6 +136,9 @@ await app.listen({ port: 3000 }) ### threshold The minimum byte size for response compression. Defaults to `1024`. + +> ℹ️ Note: The threshold setting only applies to string and Buffer payloads. Streams (including Node.js streams, Response objects, and ReadableStream objects) are always compressed regardless of the threshold, as their size cannot be determined in advance. + ```js await fastify.register( import('@fastify/compress'), @@ -320,9 +350,13 @@ 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. +### Handling Unsupported Payload Types + +When `@fastify/compress` receives a payload type that it doesn't natively support for compression (excluding the types listed in [Supported payload types](#supported-payload-types)), the behavior depends on the compression method: + +- **Using `reply.compress()`**: The plugin will attempt to serialize the payload using Fastify's `serialize` function and then compress the result. This provides a best-effort approach to handle custom objects. -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. +- **Using global compression hook**: To prevent breaking applications, the plugin will pass through unsupported payload types without compression. This fail-safe approach ensures that servers continue to function even when encountering unexpected payload types. ## Acknowledgments diff --git a/index.js b/index.js index b545173..22356f0 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const zlib = require('node:zlib') const { inherits, format } = require('node:util') +const { Readable: NodeReadable } = require('node:stream') const fp = require('fastify-plugin') const encodingNegotiator = require('@fastify/accept-negotiator') @@ -282,10 +283,24 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) { return next(null, payload) } - if (Buffer.byteLength(payload) < params.threshold) { - return next() + // Handle Response objects + if (payload instanceof Response) { + const responseStream = convertResponseToStream(payload) + if (responseStream) { + payload = responseStream + } else { + // Response has no body or body is null + return next() + } + } else if (payload instanceof ReadableStream) { + // Handle raw ReadableStream objects + payload = NodeReadable.fromWeb(payload) + } else { + if (Buffer.byteLength(payload) < params.threshold) { + return next() + } + payload = Readable.from(intoAsyncIterator(payload)) } - payload = Readable.from(intoAsyncIterator(payload)) } setVaryHeader(reply) @@ -406,10 +421,24 @@ function compress (params) { } if (typeof payload.pipe !== 'function') { - if (Buffer.byteLength(payload) < params.threshold) { - return this.send(payload) + // Handle Response objects + if (payload instanceof Response) { + const responseStream = convertResponseToStream(payload) + if (responseStream) { + payload = responseStream + } else { + // Response has no body or body is null + return this.send(payload) + } + } else if (payload instanceof ReadableStream) { + // Handle raw ReadableStream objects + payload = NodeReadable.fromWeb(payload) + } else { + if (Buffer.byteLength(payload) < params.threshold) { + return this.send(payload) + } + payload = Readable.from(intoAsyncIterator(payload)) } - payload = Readable.from(intoAsyncIterator(payload)) } setVaryHeader(this) @@ -490,7 +519,8 @@ 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' + // Also support Response objects from fetch API and ReadableStream + return Buffer.isBuffer(payload) || typeof payload === 'string' || payload instanceof Response || payload instanceof ReadableStream } function shouldCompress (type, compressibleTypes) { @@ -528,6 +558,15 @@ function maybeUnzip (payload, serialize) { return Readable.from(intoAsyncIterator(result)) } +function convertResponseToStream (payload) { + // Handle Response objects from fetch API + if (payload instanceof Response && payload.body) { + // Convert Web ReadableStream to Node.js stream + return NodeReadable.fromWeb(payload.body) + } + return null +} + function zipStream (deflate, encoding) { return peek({ newline: false, maxBuffer: 10 }, function (data, swap) { switch (isCompressed(data)) { diff --git a/test/global-compress.test.js b/test/global-compress.test.js index fcfe73e..23ce895 100644 --- a/test/global-compress.test.js +++ b/test/global-compress.test.js @@ -3384,22 +3384,21 @@ test('It should serialize and compress objects when reply.compress() receives no 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) +test('It should handle Response objects properly when using reply.compress()', async (t) => { + t.plan(3) const fastify = Fastify() await fastify.register(compressPlugin, { threshold: 0 // Ensure even small payloads get compressed }) - // Response objects serialize to "{}" in JSON + // Response objects now get their body properly extracted 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 + // Response objects now get their body extracted and compressed reply.compress(responseObject) }) @@ -3412,13 +3411,182 @@ test('It should handle Response objects by serializing them to JSON when using r }) t.assert.equal(response.statusCode, 200) - // The response gets compressed because "{}" is valid JSON content + // The response gets compressed + t.assert.equal(response.headers['content-encoding'], 'gzip') + + // Decompress the response to verify the content is the Response body + const compressedBuffer = Buffer.from(response.rawPayload) + const decompressed = zlib.gunzipSync(compressedBuffer).toString('utf8') + t.assert.equal(decompressed, testContent) +}) + +test('It should compress Response objects with body streams', async (t) => { + t.plan(3) + + const fastify = Fastify() + await fastify.register(compressPlugin, { + threshold: 0 // Ensure even small payloads get compressed + }) + + const responseBody = '{"message": "This is a test response"}' + const responseObject = new Response(responseBody, { + status: 200, + headers: { 'content-type': 'application/json' } + }) + + fastify.get('/', (_request, reply) => { + reply.header('content-type', 'application/json') + reply.send(responseObject) + }) + + const response = await fastify.inject({ + url: '/', + method: 'GET', + headers: { + 'accept-encoding': 'gzip' + } + }) + + t.assert.equal(response.statusCode, 200) + // The response should be compressed + t.assert.equal(response.headers['content-encoding'], 'gzip') + + // Decompress the response to verify the content + const compressedBuffer = Buffer.from(response.rawPayload) + const decompressed = zlib.gunzipSync(compressedBuffer).toString('utf8') + t.assert.equal(decompressed, responseBody) +}) + +test('It should compress Response objects using reply.compress()', async (t) => { + t.plan(3) + + const fastify = Fastify() + await fastify.register(compressPlugin, { + threshold: 0 // Ensure even small payloads get compressed + }) + + const responseBody = '{"message": "Compressed with reply.compress()"}' + const responseObject = new Response(responseBody, { + status: 200, + headers: { 'content-type': 'application/json' } + }) + + fastify.get('/', (_request, reply) => { + reply.header('content-type', 'application/json') + reply.compress(responseObject) + }) + + const response = await fastify.inject({ + url: '/', + method: 'GET', + headers: { + 'accept-encoding': 'gzip' + } + }) + + t.assert.equal(response.statusCode, 200) + t.assert.equal(response.headers['content-encoding'], 'gzip') + + // Decompress the response to verify the content + const compressedBuffer = Buffer.from(response.rawPayload) + const decompressed = zlib.gunzipSync(compressedBuffer).toString('utf8') + t.assert.equal(decompressed, responseBody) +}) + +test('It should handle Response objects without body', async (t) => { + t.plan(3) + + const fastify = Fastify() + await fastify.register(compressPlugin) + + // Create a Response object with null body + const responseObject = new Response(null, { + status: 204 + }) + + fastify.get('/', (_request, reply) => { + // When sending a Response object with no body, Fastify will handle it natively + reply.send(responseObject) + }) + + const response = await fastify.inject({ + url: '/', + method: 'GET', + headers: { + 'accept-encoding': 'gzip' + } + }) + + t.assert.equal(response.statusCode, 204) // Fastify preserves Response status when no body + // No compression since there's no body + t.assert.equal(response.headers['content-encoding'], undefined) + // No content with 204 status + t.assert.equal(response.payload, '') +}) + +test('It should compress raw ReadableStream objects', async (t) => { + t.plan(3) + + const fastify = Fastify() + await fastify.register(compressPlugin, { + threshold: 0 // Ensure even small payloads get compressed + }) + + const responseBody = '{"message": "This is a ReadableStream test"}' + const readableStream = new Response(responseBody).body + + fastify.get('/', (_request, reply) => { + reply.header('content-type', 'application/json') + reply.send(readableStream) + }) + + const response = await fastify.inject({ + url: '/', + method: 'GET', + headers: { + 'accept-encoding': 'gzip' + } + }) + + t.assert.equal(response.statusCode, 200) + // The response should be compressed + t.assert.equal(response.headers['content-encoding'], 'gzip') + + // Decompress the response to verify the content + const compressedBuffer = Buffer.from(response.rawPayload) + const decompressed = zlib.gunzipSync(compressedBuffer).toString('utf8') + t.assert.equal(decompressed, responseBody) +}) + +test('It should compress ReadableStream objects using reply.compress()', async (t) => { + t.plan(3) + + const fastify = Fastify() + await fastify.register(compressPlugin, { + threshold: 0 // Ensure even small payloads get compressed + }) + + const responseBody = '{"message": "Compressed ReadableStream with reply.compress()"}' + const readableStream = new Response(responseBody).body + + fastify.get('/', (_request, reply) => { + reply.header('content-type', 'application/json') + reply.compress(readableStream) + }) + + const response = await fastify.inject({ + url: '/', + method: 'GET', + headers: { + 'accept-encoding': 'gzip' + } + }) + + t.assert.equal(response.statusCode, 200) 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 + // Decompress the response to verify the content const compressedBuffer = Buffer.from(response.rawPayload) const decompressed = zlib.gunzipSync(compressedBuffer).toString('utf8') - t.assert.equal(decompressed, '{}') + t.assert.equal(decompressed, responseBody) }) diff --git a/types/index.d.ts b/types/index.d.ts index 63dd1d0..e26bb50 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -26,7 +26,7 @@ declare module 'fastify' { } interface FastifyReply { - compress(input: Stream | Input): void; + compress(input: Stream | Input | Response | ReadableStream): void; } export interface RouteOptions { From 8ede0d951e9fb1376b66bd343854dc629dfeddec Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Thu, 19 Jun 2025 11:45:00 +0900 Subject: [PATCH 4/6] test: add comprehensive integration tests for all supported payload types - Add tests using actual Fastify server with real HTTP requests - Test with both fetch and axios to ensure compatibility - Verify compression works for all supported types: - JSON objects - Plain strings - Buffers - Node.js Readable Streams - Response objects - Raw ReadableStreams - Test edge cases like empty responses and large streams - Ensure clients receive correct decompressed data --- test/integration-compress.test.js | 407 ++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 test/integration-compress.test.js diff --git a/test/integration-compress.test.js b/test/integration-compress.test.js new file mode 100644 index 0000000..5385a39 --- /dev/null +++ b/test/integration-compress.test.js @@ -0,0 +1,407 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const Fastify = require('fastify') +const zlib = require('node:zlib') +const { promisify } = require('node:util') +const { Readable: NodeReadable } = require('node:stream') +const axios = require('axios') +const fastifyCompress = require('..') + +const gunzip = promisify(zlib.gunzip) + +// Define all test cases that should work with any HTTP client +const testCases = [ + { + name: 'JSON object', + handler: async (request, reply) => { + return { hello: 'world', test: true, number: 42 } + }, + expectedBody: { hello: 'world', test: true, number: 42 }, + contentType: 'application/json; charset=utf-8' + }, + { + name: 'Plain string', + handler: async (request, reply) => { + return 'Hello World! This is a test string that should be compressed.' + }, + expectedBody: 'Hello World! This is a test string that should be compressed.', + contentType: 'text/plain; charset=utf-8' + }, + { + name: 'Buffer', + handler: async (request, reply) => { + return Buffer.from('This is a buffer content that should be compressed properly.') + }, + expectedBody: 'This is a buffer content that should be compressed properly.', + contentType: 'application/octet-stream' + }, + { + name: 'Node.js Readable Stream', + handler: async (request, reply) => { + const stream = new NodeReadable({ + read() { + this.push('Stream chunk 1. ') + this.push('Stream chunk 2. ') + this.push('Stream chunk 3.') + this.push(null) + } + }) + return stream + }, + expectedBody: 'Stream chunk 1. Stream chunk 2. Stream chunk 3.', + // Fastify doesn't set content-type for streams by default + contentType: null + }, + { + name: 'Response object with JSON', + handler: async (request, reply) => { + const body = JSON.stringify({ response: 'object', compressed: true }) + const response = new Response(body, { + status: 200, + headers: { 'content-type': 'application/json' } + }) + return response + }, + expectedBody: { response: 'object', compressed: true }, + // Response headers are not automatically copied by fastify-compress + contentType: null, + // Fastify uses default status 200 when Response is converted to stream + checkStatus: 200 + }, + { + name: 'Response object with ReadableStream', + handler: async (request, reply) => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('Response ')) + controller.enqueue(encoder.encode('with ')) + controller.enqueue(encoder.encode('ReadableStream')) + controller.close() + } + }) + const response = new Response(stream, { + status: 201, + headers: { 'content-type': 'text/plain' } + }) + return response + }, + expectedBody: 'Response with ReadableStream', + // Response headers are not automatically copied by fastify-compress + contentType: null, + // Fastify uses default status 200 when Response is converted to stream + checkStatus: 200 + }, + { + name: 'Raw ReadableStream', + handler: async (request, reply) => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('Raw ')) + controller.enqueue(encoder.encode('ReadableStream ')) + controller.enqueue(encoder.encode('content')) + controller.close() + } + }) + reply.type('text/plain') + return stream + }, + expectedBody: 'Raw ReadableStream content', + // When compression is applied, charset may be removed + contentType: 'text/plain' + }, + { + name: 'Large JSON to ensure compression', + handler: async (request, reply) => { + const largeData = { + items: Array(100).fill(null).map((_, i) => ({ + id: i, + name: `Item ${i}`, + description: 'This is a long description to ensure the content is large enough to be compressed. ' + + 'Compression typically requires content to be above a certain threshold to be effective.' + })) + } + return largeData + }, + expectedBody: (body) => { + return body.items && body.items.length === 100 && body.items[0].name === 'Item 0' + }, + contentType: 'application/json; charset=utf-8' + } +] + +// Additional test cases for edge cases +const edgeCaseTests = [ + { + name: 'Empty Response object', + handler: async (request, reply) => { + return new Response(null, { status: 204 }) + }, + expectedStatus: 204, + expectNoBody: true + }, + { + name: 'Response object with empty string body', + handler: async (request, reply) => { + return new Response('', { status: 200 }) + }, + expectedBody: '', + checkStatus: 200 + }, + { + name: 'Large stream to verify compression', + handler: async (request, reply) => { + const chunks = [] + for (let i = 0; i < 100; i++) { + chunks.push(`This is chunk ${i} with some repeated content to ensure good compression. `) + } + const stream = new NodeReadable({ + read() { + if (chunks.length > 0) { + this.push(chunks.shift()) + } else { + this.push(null) + } + } + }) + return stream + }, + expectedBody: (body) => { + return body.includes('This is chunk 0') && body.includes('This is chunk 99') + }, + contentType: null + } +] + +// Test implementation for fetch +async function testWithFetch(testCase, port) { + const response = await fetch(`http://localhost:${port}/`, { + headers: { + 'Accept-Encoding': 'gzip' + } + }) + + // Check for expected status first + if (testCase.expectedStatus) { + assert.strictEqual(response.status, testCase.expectedStatus, `${testCase.name}: should have expected status`) + } + + if (testCase.checkStatus) { + assert.strictEqual(response.status, testCase.checkStatus, `${testCase.name}: should have correct status`) + } + + // Handle empty body case (204 No Content doesn't have compression headers) + if (testCase.expectNoBody) { + const bodyText = await response.text() + assert.strictEqual(bodyText, '', `${testCase.name}: should have empty body`) + return + } + + // Verify compression headers + assert.strictEqual(response.headers.get('content-encoding'), 'gzip', `${testCase.name}: should have gzip encoding`) + assert.strictEqual(response.headers.get('vary'), 'accept-encoding', `${testCase.name}: should have vary header`) + + if (testCase.contentType !== undefined) { + assert.strictEqual(response.headers.get('content-type'), testCase.contentType, `${testCase.name}: should have correct content-type`) + } + + // Native fetch automatically decompresses gzip responses, so we can read directly + const bodyText = await response.text() + + // Verify content + if (typeof testCase.expectedBody === 'function') { + try { + const bodyJson = JSON.parse(bodyText) + assert.ok(testCase.expectedBody(bodyJson), `${testCase.name}: body validation should pass`) + } catch (e) { + // Not JSON, pass raw text + assert.ok(testCase.expectedBody(bodyText), `${testCase.name}: body validation should pass`) + } + } else if (typeof testCase.expectedBody === 'object') { + const bodyJson = JSON.parse(bodyText) + assert.deepStrictEqual(bodyJson, testCase.expectedBody, `${testCase.name}: JSON body should match`) + } else if (testCase.expectedBody !== undefined) { + assert.strictEqual(bodyText, testCase.expectedBody, `${testCase.name}: body should match`) + } +} + +// Test implementation for axios +async function testWithAxios(testCase, port) { + const response = await axios.get(`http://localhost:${port}/`, { + headers: { + 'Accept-Encoding': 'gzip' + } + // Let axios decompress automatically (default behavior) + }) + + // Check for expected status first + if (testCase.expectedStatus) { + assert.strictEqual(response.status, testCase.expectedStatus, `${testCase.name}: should have expected status`) + } + + if (testCase.checkStatus) { + assert.strictEqual(response.status, testCase.checkStatus, `${testCase.name}: should have correct status`) + } + + // Handle empty body case (204 No Content doesn't have compression headers) + if (testCase.expectNoBody) { + const bodyText = typeof response.data === 'string' ? response.data : '' + assert.strictEqual(bodyText, '', `${testCase.name}: should have empty body`) + return + } + + // Verify compression headers + // Note: axios might remove content-encoding after decompression, but vary should remain + assert.strictEqual(response.headers.vary, 'accept-encoding', `${testCase.name}: should have vary header`) + // Also check that compression actually happened (content-encoding might be removed by axios after decompression) + // We can verify this by checking the response was compressed by looking at other indicators + + if (testCase.contentType !== undefined) { + const actualContentType = response.headers['content-type'] + if (testCase.contentType === null) { + // axios returns undefined for missing headers + assert.ok(actualContentType === null || actualContentType === undefined, `${testCase.name}: should not have content-type`) + } else { + assert.strictEqual(actualContentType, testCase.contentType, `${testCase.name}: should have correct content-type`) + } + } + + // Get the response data (already decompressed by axios) + let bodyText + if (typeof response.data === 'string') { + bodyText = response.data + } else if (response.data && typeof response.data === 'object' && !Buffer.isBuffer(response.data)) { + // If axios already parsed JSON, use it directly for object comparisons + if (typeof testCase.expectedBody === 'object' && testCase.expectedBody !== null) { + assert.deepStrictEqual(response.data, testCase.expectedBody, `${testCase.name}: JSON body should match`) + return + } + // Otherwise stringify for text comparison + bodyText = JSON.stringify(response.data) + } else { + bodyText = String(response.data) + } + + // Verify content + if (typeof testCase.expectedBody === 'function') { + try { + const bodyJson = typeof response.data === 'object' ? response.data : JSON.parse(bodyText) + assert.ok(testCase.expectedBody(bodyJson), `${testCase.name}: body validation should pass`) + } catch (e) { + // Not JSON, pass raw text + assert.ok(testCase.expectedBody(bodyText), `${testCase.name}: body validation should pass`) + } + } else if (typeof testCase.expectedBody === 'object') { + const bodyJson = typeof response.data === 'object' ? response.data : JSON.parse(bodyText) + assert.deepStrictEqual(bodyJson, testCase.expectedBody, `${testCase.name}: JSON body should match`) + } else if (testCase.expectedBody !== undefined) { + assert.strictEqual(bodyText, testCase.expectedBody, `${testCase.name}: body should match`) + } +} + +// Run all test cases with both fetch and axios +test('Integration tests with real HTTP requests', async (t) => { + for (const testCase of testCases) { + await t.test(`fetch: ${testCase.name}`, async () => { + const fastify = Fastify() + // Set threshold to 0 to ensure all responses are compressed + await fastify.register(fastifyCompress, { global: true, threshold: 0 }) + + fastify.get('/', testCase.handler) + + await fastify.listen({ port: 0 }) + const port = fastify.server.address().port + + try { + await testWithFetch(testCase, port) + } finally { + await fastify.close() + } + }) + + await t.test(`axios: ${testCase.name}`, async () => { + const fastify = Fastify() + // Set threshold to 0 to ensure all responses are compressed + await fastify.register(fastifyCompress, { global: true, threshold: 0 }) + + fastify.get('/', testCase.handler) + + await fastify.listen({ port: 0 }) + const port = fastify.server.address().port + + try { + await testWithAxios(testCase, port) + } finally { + await fastify.close() + } + }) + } +}) + +// Run edge case tests +test('Edge case tests with real HTTP requests', async (t) => { + for (const testCase of edgeCaseTests) { + await t.test(`fetch: ${testCase.name}`, async () => { + const fastify = Fastify() + await fastify.register(fastifyCompress, { global: true, threshold: 0 }) + + fastify.get('/', testCase.handler) + + await fastify.listen({ port: 0 }) + const port = fastify.server.address().port + + try { + await testWithFetch(testCase, port) + } finally { + await fastify.close() + } + }) + + await t.test(`axios: ${testCase.name}`, async () => { + const fastify = Fastify() + await fastify.register(fastifyCompress, { global: true, threshold: 0 }) + + fastify.get('/', testCase.handler) + + await fastify.listen({ port: 0 }) + const port = fastify.server.address().port + + try { + await testWithAxios(testCase, port) + } finally { + await fastify.close() + } + }) + } +}) + +// Test that uncompressed responses work correctly when compression is not requested +test('Uncompressed responses when Accept-Encoding is not set', async () => { + const fastify = Fastify() + await fastify.register(fastifyCompress, { global: true }) + + fastify.get('/', async () => { + return { hello: 'world' } + }) + + await fastify.listen({ port: 0 }) + const port = fastify.server.address().port + + try { + // Test with fetch + const fetchResponse = await fetch(`http://localhost:${port}/`) + assert.strictEqual(fetchResponse.headers.get('content-encoding'), null, 'fetch: should not have content-encoding') + const fetchBody = await fetchResponse.json() + assert.deepStrictEqual(fetchBody, { hello: 'world' }, 'fetch: body should match') + + // Test with axios + const axiosResponse = await axios.get(`http://localhost:${port}/`) + assert.strictEqual(axiosResponse.headers['content-encoding'], undefined, 'axios: should not have content-encoding') + assert.deepStrictEqual(axiosResponse.data, { hello: 'world' }, 'axios: body should match') + } finally { + await fastify.close() + } +}) From 146c93393526b5779fc5dae6b08cc5e9f7972ec0 Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Thu, 19 Jun 2025 12:00:37 +0900 Subject: [PATCH 5/6] test: add got to integration tests for additional HTTP client coverage - Add got as a third HTTP client to test alongside fetch and axios - Got preserves content-encoding header after decompression, providing an additional verification that compression is working correctly - All 36 tests pass with got, confirming compatibility --- package.json | 2 + test/integration-compress.test.js | 118 +++++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index dff4705..b23c6dc 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ "@fastify/pre-commit": "^2.1.0", "@types/node": "^22.0.0", "adm-zip": "^0.5.12", + "axios": "^1.10.0", "c8": "^10.1.2", "eslint": "^9.17.0", "fastify": "^5.0.0", + "got": "^11.8.6", "jsonstream": "^1.0.3", "neostandard": "^0.12.0", "tsd": "^0.32.0", diff --git a/test/integration-compress.test.js b/test/integration-compress.test.js index 5385a39..03d279e 100644 --- a/test/integration-compress.test.js +++ b/test/integration-compress.test.js @@ -3,14 +3,11 @@ const { test } = require('node:test') const assert = require('node:assert') const Fastify = require('fastify') -const zlib = require('node:zlib') -const { promisify } = require('node:util') const { Readable: NodeReadable } = require('node:stream') const axios = require('axios') +const got = require('got') const fastifyCompress = require('..') -const gunzip = promisify(zlib.gunzip) - // Define all test cases that should work with any HTTP client const testCases = [ { @@ -41,7 +38,7 @@ const testCases = [ name: 'Node.js Readable Stream', handler: async (request, reply) => { const stream = new NodeReadable({ - read() { + read () { this.push('Stream chunk 1. ') this.push('Stream chunk 2. ') this.push('Stream chunk 3.') @@ -75,7 +72,7 @@ const testCases = [ handler: async (request, reply) => { const encoder = new TextEncoder() const stream = new ReadableStream({ - start(controller) { + start (controller) { controller.enqueue(encoder.encode('Response ')) controller.enqueue(encoder.encode('with ')) controller.enqueue(encoder.encode('ReadableStream')) @@ -99,7 +96,7 @@ const testCases = [ handler: async (request, reply) => { const encoder = new TextEncoder() const stream = new ReadableStream({ - start(controller) { + start (controller) { controller.enqueue(encoder.encode('Raw ')) controller.enqueue(encoder.encode('ReadableStream ')) controller.enqueue(encoder.encode('content')) @@ -159,7 +156,7 @@ const edgeCaseTests = [ chunks.push(`This is chunk ${i} with some repeated content to ensure good compression. `) } const stream = new NodeReadable({ - read() { + read () { if (chunks.length > 0) { this.push(chunks.shift()) } else { @@ -177,7 +174,7 @@ const edgeCaseTests = [ ] // Test implementation for fetch -async function testWithFetch(testCase, port) { +async function testWithFetch (testCase, port) { const response = await fetch(`http://localhost:${port}/`, { headers: { 'Accept-Encoding': 'gzip' @@ -229,12 +226,11 @@ async function testWithFetch(testCase, port) { } // Test implementation for axios -async function testWithAxios(testCase, port) { +async function testWithAxios (testCase, port) { const response = await axios.get(`http://localhost:${port}/`, { headers: { 'Accept-Encoding': 'gzip' } - // Let axios decompress automatically (default behavior) }) // Check for expected status first @@ -302,7 +298,66 @@ async function testWithAxios(testCase, port) { } } -// Run all test cases with both fetch and axios +// Test implementation for got +async function testWithGot(testCase, port) { + const response = await got(`http://localhost:${port}/`, { + headers: { + 'Accept-Encoding': 'gzip' + }, + // Let got handle decompression automatically (default behavior) + decompress: true + }) + + // Check for expected status first + if (testCase.expectedStatus) { + assert.strictEqual(response.statusCode, testCase.expectedStatus, `${testCase.name}: should have expected status`) + } + + if (testCase.checkStatus) { + assert.strictEqual(response.statusCode, testCase.checkStatus, `${testCase.name}: should have correct status`) + } + + // Handle empty body case (204 No Content doesn't have compression headers) + if (testCase.expectNoBody) { + assert.strictEqual(response.body, '', `${testCase.name}: should have empty body`) + return + } + + // Verify compression headers + // Got preserves the content-encoding header even after decompression + assert.strictEqual(response.headers['content-encoding'], 'gzip', `${testCase.name}: should have gzip encoding`) + assert.strictEqual(response.headers.vary, 'accept-encoding', `${testCase.name}: should have vary header`) + + if (testCase.contentType !== undefined) { + const actualContentType = response.headers['content-type'] + if (testCase.contentType === null) { + assert.ok(actualContentType === null || actualContentType === undefined, `${testCase.name}: should not have content-type`) + } else { + assert.strictEqual(actualContentType, testCase.contentType, `${testCase.name}: should have correct content-type`) + } + } + + // Get the response body (already decompressed by got) + const bodyText = response.body + + // Verify content + if (typeof testCase.expectedBody === 'function') { + try { + const bodyJson = JSON.parse(bodyText) + assert.ok(testCase.expectedBody(bodyJson), `${testCase.name}: body validation should pass`) + } catch (e) { + // Not JSON, pass raw text + assert.ok(testCase.expectedBody(bodyText), `${testCase.name}: body validation should pass`) + } + } else if (typeof testCase.expectedBody === 'object') { + const bodyJson = JSON.parse(bodyText) + assert.deepStrictEqual(bodyJson, testCase.expectedBody, `${testCase.name}: JSON body should match`) + } else if (testCase.expectedBody !== undefined) { + assert.strictEqual(bodyText, testCase.expectedBody, `${testCase.name}: body should match`) + } +} + +// Run all test cases with fetch, axios, and got test('Integration tests with real HTTP requests', async (t) => { for (const testCase of testCases) { await t.test(`fetch: ${testCase.name}`, async () => { @@ -338,6 +393,23 @@ test('Integration tests with real HTTP requests', async (t) => { await fastify.close() } }) + + await t.test(`got: ${testCase.name}`, async () => { + const fastify = Fastify() + // Set threshold to 0 to ensure all responses are compressed + await fastify.register(fastifyCompress, { global: true, threshold: 0 }) + + fastify.get('/', testCase.handler) + + await fastify.listen({ port: 0 }) + const port = fastify.server.address().port + + try { + await testWithGot(testCase, port) + } finally { + await fastify.close() + } + }) } }) @@ -375,6 +447,22 @@ test('Edge case tests with real HTTP requests', async (t) => { await fastify.close() } }) + + await t.test(`got: ${testCase.name}`, async () => { + const fastify = Fastify() + await fastify.register(fastifyCompress, { global: true, threshold: 0 }) + + fastify.get('/', testCase.handler) + + await fastify.listen({ port: 0 }) + const port = fastify.server.address().port + + try { + await testWithGot(testCase, port) + } finally { + await fastify.close() + } + }) } }) @@ -401,6 +489,12 @@ test('Uncompressed responses when Accept-Encoding is not set', async () => { const axiosResponse = await axios.get(`http://localhost:${port}/`) assert.strictEqual(axiosResponse.headers['content-encoding'], undefined, 'axios: should not have content-encoding') assert.deepStrictEqual(axiosResponse.data, { hello: 'world' }, 'axios: body should match') + + // Test with got + const gotResponse = await got(`http://localhost:${port}/`) + assert.strictEqual(gotResponse.headers['content-encoding'], undefined, 'got: should not have content-encoding') + const gotBody = JSON.parse(gotResponse.body) + assert.deepStrictEqual(gotBody, { hello: 'world' }, 'got: body should match') } finally { await fastify.close() } From 71233ca07228ba480ee4dc52c73ffd5c4bcd92f5 Mon Sep 17 00:00:00 2001 From: Bart Riepe Date: Thu, 19 Jun 2025 12:13:04 +0900 Subject: [PATCH 6/6] feat: copy headers and status from Response objects When handling Response objects from the Fetch API, the compress middleware now copies headers and status code from the Response to the reply, unless those headers have already been explicitly set on the reply. This provides more intuitive behavior - if someone returns a Response object with specific headers (like content-type, cache-control, etc.), those headers will be preserved in the final response. - Headers already set on the reply take precedence over Response headers - Status code is copied only if reply still has default 200 status - Added comprehensive tests to verify header handling behavior - Updated README to document this behavior --- README.md | 2 +- index.js | 24 ++++++++++ test/integration-compress.test.js | 61 +++++++++++++++++++++---- test/response-headers.test.js | 76 +++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 test/response-headers.test.js diff --git a/README.md b/README.md index 7ffc673..44aa9d9 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ fastify.get('/custom-route', { ### `reply.compress` This plugin adds a `compress` method to `reply` that compresses a stream or string based on the `accept-encoding` header. If a JS object is passed, it will be stringified to JSON. -> ℹ️ Note: When compressing a Response object, the compress middleware only extracts and compresses the body stream. It will handle compression-related headers (like `Content-Encoding` and `Vary`) but does not copy other headers or status from the Response object - these remain the responsibility of your application or Fastify's built-in handling. +> ℹ️ Note: When compressing a Response object, the compress middleware will copy headers and status from the Response object, unless they have already been explicitly set on the reply. The middleware will then compress the body stream and handle compression-related headers (like `Content-Encoding` and `Vary`). The `compress` method uses per-route parameters if configured, otherwise it uses global parameters. diff --git a/index.js b/index.js index 22356f0..665a59c 100644 --- a/index.js +++ b/index.js @@ -285,6 +285,18 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) { // Handle Response objects if (payload instanceof Response) { + // Copy headers from Response object unless already set + for (const [key, value] of payload.headers.entries()) { + if (!reply.hasHeader(key)) { + reply.header(key, value) + } + } + + // Set status code if it's still the default 200 and Response has a different status + if (reply.statusCode === 200 && payload.status && payload.status !== 200) { + reply.code(payload.status) + } + const responseStream = convertResponseToStream(payload) if (responseStream) { payload = responseStream @@ -423,6 +435,18 @@ function compress (params) { if (typeof payload.pipe !== 'function') { // Handle Response objects if (payload instanceof Response) { + // Copy headers from Response object unless already set + for (const [key, value] of payload.headers.entries()) { + if (!this.hasHeader(key)) { + this.header(key, value) + } + } + + // Set status code if it's still the default 200 and Response has a different status + if (this.statusCode === 200 && payload.status && payload.status !== 200) { + this.code(payload.status) + } + const responseStream = convertResponseToStream(payload) if (responseStream) { payload = responseStream diff --git a/test/integration-compress.test.js b/test/integration-compress.test.js index 03d279e..283554a 100644 --- a/test/integration-compress.test.js +++ b/test/integration-compress.test.js @@ -62,9 +62,8 @@ const testCases = [ return response }, expectedBody: { response: 'object', compressed: true }, - // Response headers are not automatically copied by fastify-compress - contentType: null, - // Fastify uses default status 200 when Response is converted to stream + // Response headers are now copied by fastify-compress + contentType: 'application/json', checkStatus: 200 }, { @@ -86,10 +85,10 @@ const testCases = [ return response }, expectedBody: 'Response with ReadableStream', - // Response headers are not automatically copied by fastify-compress - contentType: null, - // Fastify uses default status 200 when Response is converted to stream - checkStatus: 200 + // Response headers are now copied by fastify-compress + contentType: 'text/plain', + // Status code is now preserved from Response object + checkStatus: 201 }, { name: 'Raw ReadableStream', @@ -148,6 +147,29 @@ const edgeCaseTests = [ expectedBody: '', checkStatus: 200 }, + { + name: 'Response object with overridden headers', + handler: async (request, reply) => { + // Set headers on reply first + reply.header('content-type', 'text/html') + reply.code(202) + + // Return Response with different headers + const response = new Response('Hello World', { + status: 200, + headers: { 'content-type': 'text/plain', 'x-custom-header': 'test' } + }) + return response + }, + expectedBody: 'Hello World', + // Reply headers should take precedence + contentType: 'text/html', + checkStatus: 202, + // Custom header from Response should be preserved + checkHeaders: { + 'x-custom-header': 'test' + } + }, { name: 'Large stream to verify compression', handler: async (request, reply) => { @@ -205,6 +227,13 @@ async function testWithFetch (testCase, port) { assert.strictEqual(response.headers.get('content-type'), testCase.contentType, `${testCase.name}: should have correct content-type`) } + // Check custom headers if specified + if (testCase.checkHeaders) { + for (const [key, value] of Object.entries(testCase.checkHeaders)) { + assert.strictEqual(response.headers.get(key), value, `${testCase.name}: should have header ${key}=${value}`) + } + } + // Native fetch automatically decompresses gzip responses, so we can read directly const bodyText = await response.text() @@ -265,6 +294,13 @@ async function testWithAxios (testCase, port) { } } + // Check custom headers if specified + if (testCase.checkHeaders) { + for (const [key, value] of Object.entries(testCase.checkHeaders)) { + assert.strictEqual(response.headers[key], value, `${testCase.name}: should have header ${key}=${value}`) + } + } + // Get the response data (already decompressed by axios) let bodyText if (typeof response.data === 'string') { @@ -299,7 +335,7 @@ async function testWithAxios (testCase, port) { } // Test implementation for got -async function testWithGot(testCase, port) { +async function testWithGot (testCase, port) { const response = await got(`http://localhost:${port}/`, { headers: { 'Accept-Encoding': 'gzip' @@ -312,7 +348,7 @@ async function testWithGot(testCase, port) { if (testCase.expectedStatus) { assert.strictEqual(response.statusCode, testCase.expectedStatus, `${testCase.name}: should have expected status`) } - + if (testCase.checkStatus) { assert.strictEqual(response.statusCode, testCase.checkStatus, `${testCase.name}: should have correct status`) } @@ -337,6 +373,13 @@ async function testWithGot(testCase, port) { } } + // Check custom headers if specified + if (testCase.checkHeaders) { + for (const [key, value] of Object.entries(testCase.checkHeaders)) { + assert.strictEqual(response.headers[key], value, `${testCase.name}: should have header ${key}=${value}`) + } + } + // Get the response body (already decompressed by got) const bodyText = response.body diff --git a/test/response-headers.test.js b/test/response-headers.test.js new file mode 100644 index 0000000..ca87e6f --- /dev/null +++ b/test/response-headers.test.js @@ -0,0 +1,76 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const Fastify = require('fastify') +const fastifyCompress = require('..') + +test('It should copy headers from Response objects', async () => { + const fastify = Fastify() + await fastify.register(fastifyCompress, { threshold: 0 }) + + fastify.get('/', async (request, reply) => { + const response = new Response('Hello World', { + status: 201, + headers: { + 'content-type': 'text/plain', + 'x-custom-header': 'test-value', + 'cache-control': 'no-cache' + } + }) + return response + }) + + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'accept-encoding': 'gzip' + } + }) + + assert.equal(response.statusCode, 201) + assert.equal(response.headers['content-type'], 'text/plain') + assert.equal(response.headers['x-custom-header'], 'test-value') + assert.equal(response.headers['cache-control'], 'no-cache') + assert.equal(response.headers['content-encoding'], 'gzip') +}) + +test('It should not override headers already set on reply', async () => { + const fastify = Fastify() + await fastify.register(fastifyCompress, { threshold: 0 }) + + fastify.get('/', async (request, reply) => { + // Set headers on reply first + reply.header('content-type', 'text/html') + reply.header('x-custom-header', 'reply-value') + reply.code(202) + + // Return Response with different headers + const response = new Response('Hello World', { + status: 201, + headers: { + 'content-type': 'text/plain', + 'x-custom-header': 'response-value', + 'x-another-header': 'test' + } + }) + return response + }) + + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + 'accept-encoding': 'gzip' + } + }) + + // Reply headers should take precedence + assert.equal(response.statusCode, 202) + assert.equal(response.headers['content-type'], 'text/html') + assert.equal(response.headers['x-custom-header'], 'reply-value') + // But Response headers not already set should be added + assert.equal(response.headers['x-another-header'], 'test') + assert.equal(response.headers['content-encoding'], 'gzip') +}) \ No newline at end of file