diff --git a/.gitignore b/.gitignore index 2b6aed4..c117a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,10 @@ yarn.lock # editor files .vscode .idea +.zed #tap files .tap/ + +# Claude AI +.claude/ diff --git a/README.md b/README.md index 7953282..44aa9d9 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 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. ```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'), @@ -318,6 +348,16 @@ await fastify.register( ) ``` +## Gotchas + +### 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. + +- **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 Past sponsors: diff --git a/index.js b/index.js index 0aabf9b..665a59c 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') @@ -161,6 +162,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,10 +278,41 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) { } if (typeof payload.pipe !== 'function') { - if (Buffer.byteLength(payload) < params.threshold) { - return next() + // 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) + } + + // 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 + } 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) @@ -391,16 +427,42 @@ function compress (params) { } if (typeof payload.pipe !== 'function') { - if (!Buffer.isBuffer(payload) && typeof payload !== 'string') { + if (!params.isCompressiblePayload(payload)) { payload = this.serialize(payload) } } if (typeof payload.pipe !== 'function') { - if (Buffer.byteLength(payload) < params.threshold) { - return this.send(payload) + // 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 + } 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) @@ -477,6 +539,14 @@ 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 + // 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) { if (compressibleTypes(type)) return true const data = mimedb[type.split(';', 1)[0].trim().toLowerCase()] @@ -512,6 +582,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/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/global-compress.test.js b/test/global-compress.test.js index 4c142bc..23ce895 100644 --- a/test/global-compress.test.js +++ b/test/global-compress.test.js @@ -3298,3 +3298,295 @@ 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 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 now get their body properly extracted + const testContent = 'test content for compression' + const responseObject = new Response(testContent) + + fastify.get('/', (_request, reply) => { + reply.header('content-type', 'application/json') + // Response objects now get their body extracted and compressed + 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 + 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') + + // 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) +}) diff --git a/test/integration-compress.test.js b/test/integration-compress.test.js new file mode 100644 index 0000000..283554a --- /dev/null +++ b/test/integration-compress.test.js @@ -0,0 +1,544 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const Fastify = require('fastify') +const { Readable: NodeReadable } = require('node:stream') +const axios = require('axios') +const got = require('got') +const fastifyCompress = require('..') + +// 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 now copied by fastify-compress + contentType: 'application/json', + 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 now copied by fastify-compress + contentType: 'text/plain', + // Status code is now preserved from Response object + checkStatus: 201 + }, + { + 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: '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) => { + 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`) + } + + // 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() + + // 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' + } + }) + + // 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`) + } + } + + // 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') { + 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`) + } +} + +// 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`) + } + } + + // 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 + + // 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 () => { + 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() + } + }) + + 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() + } + }) + } +}) + +// 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() + } + }) + + 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() + } + }) + } +}) + +// 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') + + // 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() + } +}) 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 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 {