diff --git a/src/middleware/compress/index.test.ts b/src/middleware/compress/index.test.ts index c1b7e17f..60bf260d 100644 --- a/src/middleware/compress/index.test.ts +++ b/src/middleware/compress/index.test.ts @@ -1,92 +1,187 @@ -import { Hono } from '../../hono' import { compress } from '.' +import { stream } from '../../helper/streaming' +import { Hono } from '../../hono' -describe('Parse Compress Middleware', () => { +describe('Compress Middleware', () => { const app = new Hono() + // Apply compress middleware to all routes app.use('*', compress()) - app.get('/hello', async (ctx) => { - ctx.header('Content-Length', '5') - return ctx.text('hello') + + // Test routes + app.get('/small', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '5') + return c.text('small') }) - app.notFound((c) => { - return c.text('Custom NotFound', 404) + app.get('/large', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '1024') + return c.text('a'.repeat(1024)) }) - - it('gzip', async () => { - const req = new Request('http://localhost/hello', { - method: 'GET', - headers: new Headers({ 'Accept-Encoding': 'gzip' }), - }) - const res = await app.request(req) - expect(res).not.toBeNull() - expect(res.status).toBe(200) - expect(res.headers.get('Content-Encoding')).toEqual('gzip') - expect(res.headers.get('Content-Length')).toBeNull() + app.get('/small-json', (c) => { + c.header('Content-Type', 'application/json') + c.header('Content-Length', '26') + return c.json({ message: 'Hello, World!' }) }) - - it('deflate', async () => { - const req = new Request('http://localhost/hello', { - method: 'GET', - headers: new Headers({ 'Accept-Encoding': 'deflate' }), - }) - const res = await app.request(req) - expect(res).not.toBeNull() - expect(res.status).toBe(200) - expect(res.headers.get('Content-Encoding')).toEqual('deflate') - expect(res.headers.get('Content-Length')).toBeNull() + app.get('/large-json', (c) => { + c.header('Content-Type', 'application/json') + c.header('Content-Length', '1024') + return c.json({ data: 'a'.repeat(1024), message: 'Large JSON' }) }) - - it('gzip or deflate', async () => { - const req = new Request('http://localhost/hello', { - method: 'GET', - headers: new Headers({ 'Accept-Encoding': 'gzip, deflate' }), - }) - const res = await app.request(req) - expect(res).not.toBeNull() - expect(res.status).toBe(200) - expect(res.headers.get('Content-Encoding')).toEqual('gzip') - expect(res.headers.get('Content-Length')).toBeNull() + app.get('/no-transform', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '1024') + c.header('Cache-Control', 'no-transform') + return c.text('a'.repeat(1024)) }) - - it('raw', async () => { - const req = new Request('http://localhost/hello', { - method: 'GET', - }) - const res = await app.request(req) - expect(res).not.toBeNull() - expect(res.status).toBe(200) - expect(res.headers.get('Content-Encoding')).toBeNull() - expect(res.headers.get('Content-Length')).toBe('5') + app.get('/jpeg-image', (c) => { + c.header('Content-Type', 'image/jpeg') + c.header('Content-Length', '1024') + return c.body(new Uint8Array(1024)) // Simulated JPEG data }) - - it('Should handle Custom 404 Not Found', async () => { - const req = new Request('http://localhost/not-found', { - method: 'GET', - headers: new Headers({ 'Accept-Encoding': 'gzip' }), - }) - const res = await app.request(req) - expect(res).not.toBeNull() - expect(res.status).toBe(404) - expect(res.headers.get('Content-Encoding')).toEqual('gzip') - - // decompress response body - const decompressionStream = new DecompressionStream('gzip') - const decompressedStream = res.body!.pipeThrough(decompressionStream) - - const textDecoder = new TextDecoder() - const reader = decompressedStream.getReader() - let text = '' - - for (;;) { - const { done, value } = await reader.read() - if (done) { - break + app.get('/already-compressed', (c) => { + c.header('Content-Type', 'application/octet-stream') + c.header('Content-Encoding', 'br') + c.header('Content-Length', '1024') + return c.body(new Uint8Array(1024)) // Simulated compressed data + }) + app.get('/stream', (c) => + stream(c, async (stream) => { + c.header('Content-Type', 'text/plain') + // 60000 bytes + for (let i = 0; i < 10000; i++) { + await stream.write('chunk ') } - text += textDecoder.decode(value, { stream: true }) - } + }) + ) + app.get('/already-compressed-stream', (c) => + stream(c, async (stream) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Encoding', 'br') + // 60000 bytes + for (let i = 0; i < 10000; i++) { + await stream.write(new Uint8Array([0, 1, 2, 3, 4, 5])) // Simulated compressed data + } + }) + ) + app.notFound((c) => c.text('Custom NotFound', 404)) - text += textDecoder.decode() - expect(text).toBe('Custom NotFound') + const testCompression = async ( + path: string, + acceptEncoding: string, + expectedEncoding: string | null + ) => { + const req = new Request(`http://localhost${path}`, { + method: 'GET', + headers: new Headers({ 'Accept-Encoding': acceptEncoding }), + }) + const res = await app.request(req) + expect(res.headers.get('Content-Encoding')).toBe(expectedEncoding) + return res + } + + describe('Compression Behavior', () => { + it('should compress large responses with gzip', async () => { + const res = await testCompression('/large', 'gzip', 'gzip') + expect(res.headers.get('Content-Length')).toBeNull() + expect((await res.arrayBuffer()).byteLength).toBeLessThan(1024) + }) + + it('should compress large responses with deflate', async () => { + const res = await testCompression('/large', 'deflate', 'deflate') + expect((await res.arrayBuffer()).byteLength).toBeLessThan(1024) + }) + + it('should prioritize gzip over deflate when both are accepted', async () => { + await testCompression('/large', 'gzip, deflate', 'gzip') + }) + + it('should not compress small responses', async () => { + const res = await testCompression('/small', 'gzip, deflate', null) + expect(res.headers.get('Content-Length')).toBe('5') + }) + + it('should not compress when no Accept-Encoding is provided', async () => { + await testCompression('/large', '', null) + }) + + it('should not compress images', async () => { + const res = await testCompression('/jpeg-image', 'gzip', null) + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + expect(res.headers.get('Content-Length')).toBe('1024') + }) + + it('should not compress already compressed responses', async () => { + const res = await testCompression('/already-compressed', 'gzip', 'br') + expect(res.headers.get('Content-Length')).toBe('1024') + }) + + it('should remove Content-Length when compressing', async () => { + const res = await testCompression('/large', 'gzip', 'gzip') + expect(res.headers.get('Content-Length')).toBeNull() + }) + + it('should not remove Content-Length when not compressing', async () => { + const res = await testCompression('/jpeg-image', 'gzip', null) + expect(res.headers.get('Content-Length')).toBeDefined() + }) + }) + + describe('JSON Handling', () => { + it('should not compress small JSON responses', async () => { + const res = await testCompression('/small-json', 'gzip', null) + expect(res.headers.get('Content-Length')).toBe('26') + }) + + it('should compress large JSON responses', async () => { + const res = await testCompression('/large-json', 'gzip', 'gzip') + expect(res.headers.get('Content-Length')).toBeNull() + const decompressed = await decompressResponse(res) + const json = JSON.parse(decompressed) + expect(json.data.length).toBe(1024) + expect(json.message).toBe('Large JSON') + }) + }) + + describe('Streaming Responses', () => { + it('should compress streaming responses written in multiple chunks', async () => { + const res = await testCompression('/stream', 'gzip', 'gzip') + const decompressed = await decompressResponse(res) + expect(decompressed.length).toBe(60000) + }) + + it('should not compress already compressed streaming responses', async () => { + const res = await testCompression('/already-compressed-stream', 'gzip', 'br') + expect((await res.arrayBuffer()).byteLength).toBe(60000) + }) + }) + + describe('Edge Cases', () => { + it('should not compress responses with Cache-Control: no-transform', async () => { + await testCompression('/no-transform', 'gzip', null) + }) + + it('should handle HEAD requests without compression', async () => { + const req = new Request('http://localhost/large', { + method: 'HEAD', + headers: new Headers({ 'Accept-Encoding': 'gzip' }), + }) + const res = await app.request(req) + expect(res.headers.get('Content-Encoding')).toBeNull() + }) + + it('should compress custom 404 Not Found responses', async () => { + const res = await testCompression('/not-found', 'gzip', 'gzip') + expect(res.status).toBe(404) + const decompressed = await decompressResponse(res) + expect(decompressed).toBe('Custom NotFound') + }) }) }) + +async function decompressResponse(res: Response): Promise { + const decompressedStream = res.body!.pipeThrough(new DecompressionStream('gzip')) + const decompressedResponse = new Response(decompressedStream) + return await decompressedResponse.text() +} diff --git a/src/middleware/compress/index.ts b/src/middleware/compress/index.ts index bacdc52f..f77e1d8e 100644 --- a/src/middleware/compress/index.ts +++ b/src/middleware/compress/index.ts @@ -6,9 +6,13 @@ import type { MiddlewareHandler } from '../../types' const ENCODING_TYPES = ['gzip', 'deflate'] as const +const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i +const compressibleContentTypeRegExp = + /^\s*(?:text\/[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i interface CompressionOptions { encoding?: (typeof ENCODING_TYPES)[number] + threshold?: number } /** @@ -18,6 +22,7 @@ interface CompressionOptions { * * @param {CompressionOptions} [options] - The options for the compress middleware. * @param {'gzip' | 'deflate'} [options.encoding] - The compression scheme to allow for response compression. Either 'gzip' or 'deflate'. If not defined, both are allowed and will be used based on the Accept-Encoding header. 'gzip' is prioritized if this option is not provided and the client provides both in the Accept-Encoding header. + * @param {number} [options.threshold=1024] - The minimum size in bytes to compress. Defaults to 1024 bytes. * @returns {MiddlewareHandler} The middleware handler function. * * @example @@ -28,19 +33,47 @@ interface CompressionOptions { * ``` */ export const compress = (options?: CompressionOptions): MiddlewareHandler => { + const threshold = options?.threshold ?? 1024 + return async function compress(ctx, next) { await next() + + const contentLength = ctx.res.headers.get('Content-Length') + + // Check if response should be compressed + if ( + ctx.res.headers.has('Content-Encoding') || // already encoded + ctx.req.method === 'HEAD' || // HEAD request + (contentLength && Number(contentLength) < threshold) || // content-length below threshold + !shouldCompress(ctx.res) || // not compressible type + !shouldTransform(ctx.res) // cache-control: no-transform + ) { + return + } + const accepted = ctx.req.header('Accept-Encoding') const encoding = options?.encoding ?? ENCODING_TYPES.find((encoding) => accepted?.includes(encoding)) if (!encoding || !ctx.res.body) { return } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + + // Compress the response const stream = new CompressionStream(encoding) ctx.res = new Response(ctx.res.body.pipeThrough(stream), ctx.res) ctx.res.headers.delete('Content-Length') ctx.res.headers.set('Content-Encoding', encoding) } } + +const shouldCompress = (res: Response) => { + const type = res.headers.get('Content-Type') + return type && compressibleContentTypeRegExp.test(type) +} + +const shouldTransform = (res: Response) => { + const cacheControl = res.headers.get('Cache-Control') + // Don't compress for Cache-Control: no-transform + // https://tools.ietf.org/html/rfc7234#section-5.2.2.4 + return !cacheControl || !cacheControlNoTransformRegExp.test(cacheControl) +}