diff --git a/packages/event-handler/src/http/constants.ts b/packages/event-handler/src/http/constants.ts index 52eda51c41..58721d5397 100644 --- a/packages/event-handler/src/http/constants.ts +++ b/packages/event-handler/src/http/constants.ts @@ -109,6 +109,8 @@ const DEFAULT_COMPRESSION_RESPONSE_THRESHOLD = 1024; const CACHE_CONTROL_NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i; +const DECIMAL_QVALUE_REGEX = /^(?:\d+(?:\.\d*)?|\.\d+)$/; + const COMPRESSION_ENCODING_TYPES = { GZIP: 'gzip', DEFLATE: 'deflate', @@ -207,6 +209,7 @@ const HttpStatusText: Record = { export { CACHE_CONTROL_NO_TRANSFORM_REGEX, COMPRESSION_ENCODING_TYPES, + DECIMAL_QVALUE_REGEX, DEFAULT_COMPRESSION_RESPONSE_THRESHOLD, DEFAULT_CORS_OPTIONS, HttpStatusCodes, diff --git a/packages/event-handler/src/http/middleware/compress.ts b/packages/event-handler/src/http/middleware/compress.ts index 26ceb5564f..e7b7e08acb 100644 --- a/packages/event-handler/src/http/middleware/compress.ts +++ b/packages/event-handler/src/http/middleware/compress.ts @@ -3,6 +3,7 @@ import type { Middleware } from '../../types/index.js'; import { CACHE_CONTROL_NO_TRANSFORM_REGEX, COMPRESSION_ENCODING_TYPES, + DECIMAL_QVALUE_REGEX, DEFAULT_COMPRESSION_RESPONSE_THRESHOLD, } from '../constants.js'; @@ -90,14 +91,75 @@ const compress = (options?: CompressionOptions): Middleware => { }; }; +/** + * Gets the quality value from an Accept-Encoding coding's parameters. + * + * Missing `q` defaults to 1; plain decimals (e.g. `1`, `0.5`, `.5`) are capped at 1; anything else returns 0. + * + * Quality values: https://www.rfc-editor.org/rfc/rfc9110.html#section-12.4.2 + * + * @param parameters - The coding parameters to inspect + */ +const getQuality = (parameters: string[]): number => { + for (const parameter of parameters) { + const separator = parameter.indexOf('='); + const name = separator === -1 ? parameter : parameter.slice(0, separator); + + if (name.trim().toLowerCase() !== 'q') continue; + if (separator === -1) return 0; + + const value = parameter.slice(separator + 1).trim(); + if (!DECIMAL_QVALUE_REGEX.test(value)) return 0; + + return Math.min(Number(value), 1); + } + + return 1; +}; + +/** + * Checks whether the preferred compression encoding is at least as acceptable as identity. + * + * When both have the same quality, the server preference for compression wins. + * + * @param header - The value of the Accept-Encoding header from the request + * @param preferredEncoding - The preferred compression encoding to use + */ +const acceptsEncoding = ( + header: string | null, + preferredEncoding: NonNullable +): boolean => { + if (header === null) return true; + + // Exact coding matches take precedence over the `*` wildcard. + let preferredQuality: number | undefined; + let identityQuality: number | undefined; + let wildcardQuality: number | undefined; + for (const entry of header.split(',')) { + const [rawCoding, ...parameters] = entry.split(';'); + let coding = rawCoding.trim().toLowerCase(); + if (coding === 'x-gzip') coding = COMPRESSION_ENCODING_TYPES.GZIP; // RFC 9110 ยง8.4.1.3 alias + const quality = getQuality(parameters); + + if (coding === preferredEncoding) preferredQuality ??= quality; + if (coding === COMPRESSION_ENCODING_TYPES.IDENTITY) + identityQuality ??= quality; + if (coding === COMPRESSION_ENCODING_TYPES.ANY) wildcardQuality ??= quality; + } + + const compressionQuality = preferredQuality ?? wildcardQuality ?? 0; + const uncompressedQuality = + identityQuality ?? wildcardQuality ?? compressionQuality; + + return compressionQuality > 0 && compressionQuality >= uncompressedQuality; +}; + const shouldCompress = ( request: Request, response: Response, preferredEncoding: NonNullable, threshold: NonNullable ): response is Response & { body: NonNullable } => { - const acceptedEncoding = - request.headers.get('accept-encoding') ?? COMPRESSION_ENCODING_TYPES.ANY; const contentLength = response.headers.get('content-length'); const cacheControl = response.headers.get('cache-control'); @@ -105,10 +167,10 @@ const shouldCompress = ( response.headers.has('content-encoding') || response.headers.has('transfer-encoding'); - const shouldEncode = - !acceptedEncoding.includes(COMPRESSION_ENCODING_TYPES.IDENTITY) && - (acceptedEncoding.includes(preferredEncoding) || - acceptedEncoding.includes(COMPRESSION_ENCODING_TYPES.ANY)); + const shouldEncode = acceptsEncoding( + request.headers.get('accept-encoding'), + preferredEncoding + ); return ( shouldEncode && diff --git a/packages/event-handler/tests/unit/http/middleware/compress.test.ts b/packages/event-handler/tests/unit/http/middleware/compress.test.ts index 7d05a35465..8061bef16b 100644 --- a/packages/event-handler/tests/unit/http/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/http/middleware/compress.test.ts @@ -15,6 +15,19 @@ describe('Compress Middleware', () => { let app: Router; const body = { test: 'x'.repeat(2000) }; + const createDeflateApp = () => { + const application = new Router(); + application.get( + '/test', + [ + compress({ encoding: 'deflate' }), + createSettingHeadersMiddleware({ 'content-length': '2000' }), + ], + () => body + ); + return application; + }; + beforeEach(() => { app = new Router(); app.use(compress()); @@ -232,6 +245,150 @@ describe('Compress Middleware', () => { const result = await app.resolve(noCompressionEvent, context); // Assess + expect(result.statusCode).toBe(200); + expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); + }); + + it('does not compress when Accept-Encoding includes gzip with q=0', async () => { + // Prepare + const noCompressionEvent = createTestEvent('/test', 'GET', { + 'Accept-Encoding': 'gzip;q=0', + }); + app.get('/test', () => body); + + // Act + const result = await app.resolve(noCompressionEvent, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); + }); + + it('compresses when Accept-Encoding includes gzip and identity', async () => { + // Prepare + const compressionEvent = createTestEvent('/test', 'GET', { + 'Accept-Encoding': 'gzip, identity', + }); + app.get('/test', () => body); + + // Act + const result = await app.resolve(compressionEvent, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.headers?.['content-encoding']).toBe('gzip'); + expect(result.isBase64Encoded).toBe(true); + }); + + it.each([ + { compressed: false, header: '' }, + { compressed: true, header: 'GZIP' }, + { compressed: true, header: 'x-gzip' }, + { compressed: true, header: 'x-Gzip' }, + { compressed: false, header: 'x-gzip;q=0, *;q=1' }, + { compressed: false, header: 'gzip;Q=0' }, + { + compressed: false, + header: 'gzip ; q=0.5 , identity', + }, + { + compressed: true, + header: 'gzip;q=1, identity;q=0.1', + }, + { + compressed: true, + header: 'gzip;q=0.5, identity;q=0.5', + }, + { compressed: false, header: 'gzip ; q=0' }, + { compressed: false, header: 'gzipx, x-deflate' }, + { compressed: true, header: ', ,gzip' }, + { compressed: false, header: 'gzip;q=' }, + { compressed: false, header: 'gzip;q=invalid' }, + { compressed: false, header: 'gzip;q=Infinity' }, + { compressed: true, header: 'gzip;q=1.1' }, + { compressed: false, header: 'gzip;q=-1' }, + { compressed: true, header: 'gzip;q=0.5' }, + { compressed: true, header: 'gzip;\tq=0.5\t' }, + { compressed: true, header: 'gzip;q=1.0000' }, + { compressed: false, header: 'gzip;q=1e-1' }, + { compressed: false, header: 'gzip;q=0x1' }, + { compressed: false, header: 'gzip;q=0o1' }, + { compressed: false, header: 'gzip;q=0b1' }, + { compressed: true, header: 'gzip;q=0.1234' }, + { compressed: true, header: 'gzip;q= 0.5' }, + { compressed: true, header: 'gzip;q = 0.5' }, + { compressed: true, header: 'gzip;q=.5' }, + { compressed: false, header: 'gzip;q=1foo' }, + { compressed: false, header: 'gzip;q=NaN' }, + { compressed: false, header: 'gzip;q=0.5=bad' }, + { compressed: false, header: 'gzip;q =0' }, + { compressed: false, header: 'gzip;q' }, + { compressed: false, header: 'gzip;q=0..' }, + { compressed: false, header: 'gzip;q=0.' }, + { compressed: true, header: 'gzip;q=1.' }, + { compressed: true, header: 'gzip;q=1.000' }, + { compressed: true, header: 'gzip;q=0.001' }, + { compressed: true, header: 'gzip;foo=bar' }, + { compressed: true, header: 'gzip;foo=bar;q=1' }, + { compressed: false, header: 'gzip;q=0, *;q=1' }, + { compressed: false, header: 'gzip;q=invalid, *;q=1' }, + { compressed: false, header: '*;q=0' }, + { compressed: true, header: 'identity;q=0, *;q=1' }, + { compressed: false, header: 'gzip;q=0, gzip' }, + { compressed: false, header: '*;q=0, *' }, + { compressed: true, header: '*;q=0, gzip' }, + { compressed: true, header: 'identity;q=0, gzip' }, + ])( + 'returns compressed: $compressed for Accept-Encoding "$header"', + async ({ compressed, header }) => { + // Prepare + const acceptEncodingEvent = createTestEvent('/test', 'GET', { + 'Accept-Encoding': header, + }); + app.get('/test', () => body); + + // Act + const result = await app.resolve(acceptEncodingEvent, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.headers?.['content-encoding']).toBe( + compressed ? 'gzip' : undefined + ); + expect(result.isBase64Encoded).toBe(compressed); + } + ); + + it('compresses with deflate when Accept-Encoding accepts deflate', async () => { + // Prepare + const application = createDeflateApp(); + const deflateEvent = createTestEvent('/test', 'GET', { + 'Accept-Encoding': 'deflate;q=0.5', + }); + + // Act + const result = await application.resolve(deflateEvent, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.headers?.['content-encoding']).toBe('deflate'); + expect(result.isBase64Encoded).toBe(true); + }); + + it('does not negotiate down to an accepted encoding other than the configured one', async () => { + // Prepare + const application = createDeflateApp(); + const gzipOnlyEvent = createTestEvent('/test', 'GET', { + 'Accept-Encoding': 'gzip', + }); + + // Act + const result = await application.resolve(gzipOnlyEvent, context); + + // Assess + expect(result.statusCode).toBe(200); expect(result.headers?.['content-encoding']).toBeUndefined(); expect(result.isBase64Encoded).toBe(false); });