From b961b7819e097fb99230e3f2a71a58bdbcd5464f Mon Sep 17 00:00:00 2001 From: svozza Date: Sat, 29 Nov 2025 15:35:34 +0000 Subject: [PATCH 1/4] fix(event-handler): threshold limit for compression not respected when content-length not set --- packages/event-handler/src/http/Router.ts | 22 ++++++-- .../src/http/middleware/compress.ts | 8 +++ packages/event-handler/src/types/http.ts | 3 ++ .../tests/unit/http/Router/streaming.test.ts | 15 ++++++ .../unit/http/middleware/compress.test.ts | 53 ++++++++++++++++++- 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/packages/event-handler/src/http/Router.ts b/packages/event-handler/src/http/Router.ts index 46a2851009..8dd7889b20 100644 --- a/packages/event-handler/src/http/Router.ts +++ b/packages/event-handler/src/http/Router.ts @@ -24,6 +24,7 @@ import type { ErrorHandler, ErrorResolveOptions, HttpMethod, + HttpResolveOptions, HttpRouteOptions, HttpRouterOptions, Middleware, @@ -221,7 +222,7 @@ class Router { async #resolve( event: unknown, context: Context, - options?: ResolveOptions + options?: HttpResolveOptions ): Promise { if ( !isAPIGatewayProxyEventV1(event) && @@ -248,7 +249,12 @@ class Router { event, context, req: new Request('https://invalid'), - res: new Response('', { status: HttpStatusCodes.METHOD_NOT_ALLOWED }), + res: new Response(null, { + status: HttpStatusCodes.METHOD_NOT_ALLOWED, + ...(options?.isHttpStreaming && { + headers: { 'transfer-encoding': 'chunked' }, + }), + }), params: {}, responseType, }; @@ -262,7 +268,12 @@ class Router { req, // this response should be overwritten by the handler, if it isn't // it means something went wrong with the middleware chain - res: new Response('', { status: HttpStatusCodes.INTERNAL_SERVER_ERROR }), + res: new Response('', { + status: HttpStatusCodes.INTERNAL_SERVER_ERROR, + ...(options?.isHttpStreaming && { + headers: { 'transfer-encoding': 'chunked' }, + }), + }), params: {}, responseType, }; @@ -393,7 +404,10 @@ class Router { context: Context, options: ResolveStreamOptions ): Promise { - const reqCtx = await this.#resolve(event, context, options); + const reqCtx = await this.#resolve(event, context, { + ...options, + isHttpStreaming: true, + }); await this.#streamHandlerResponse(reqCtx, options.responseStream); } diff --git a/packages/event-handler/src/http/middleware/compress.ts b/packages/event-handler/src/http/middleware/compress.ts index 6fce79bb09..26ceb5564f 100644 --- a/packages/event-handler/src/http/middleware/compress.ts +++ b/packages/event-handler/src/http/middleware/compress.ts @@ -70,6 +70,14 @@ const compress = (options?: CompressionOptions): Middleware => { return async ({ reqCtx, next }) => { await next(); + const transferEncoding = reqCtx.res.headers.get('transfer-encoding'); + if (transferEncoding === 'chunked') return; + + if (reqCtx.res.body !== null && !reqCtx.res.headers.has('content-length')) { + const body = await reqCtx.res.clone().arrayBuffer(); + reqCtx.res.headers.set('content-length', body.byteLength.toString()); + } + if (!shouldCompress(reqCtx.req, reqCtx.res, preferredEncoding, threshold)) { return; } diff --git a/packages/event-handler/src/types/http.ts b/packages/event-handler/src/types/http.ts index 9f949a409d..8d7cd898ce 100644 --- a/packages/event-handler/src/types/http.ts +++ b/packages/event-handler/src/types/http.ts @@ -34,6 +34,8 @@ type RequestContext = { isBase64Encoded?: boolean; }; +type HttpResolveOptions = ResolveOptions & { isHttpStreaming?: boolean }; + type ErrorResolveOptions = RequestContext & ResolveOptions; type ErrorHandler = ( @@ -263,6 +265,7 @@ export type { ErrorHandler, ErrorResolveOptions, HandlerResponse, + HttpResolveOptions, HttpStatusCode, HttpMethod, Middleware, diff --git a/packages/event-handler/tests/unit/http/Router/streaming.test.ts b/packages/event-handler/tests/unit/http/Router/streaming.test.ts index ce0c9accb9..111c025f82 100644 --- a/packages/event-handler/tests/unit/http/Router/streaming.test.ts +++ b/packages/event-handler/tests/unit/http/Router/streaming.test.ts @@ -357,4 +357,19 @@ describe.each([ expect(result.statusCode).toBe(200); expect(JSON.parse(result.body)).toEqual({ message: 'duplex stream body' }); }); + + it('handles invalid HTTP method', async () => { + // Prepare + const app = new Router(); + const handler = streamify(app); + const responseStream = new ResponseStream(); + const event = createEvent('/test', 'TRACE'); + + // Act + const result = await handler(event, responseStream, context); + + // Assess + expect(result.statusCode).toBe(405); + expect(result.body).toBe(''); + }); }); 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 3d72667b28..d60a460d7b 100644 --- a/packages/event-handler/tests/unit/http/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/http/middleware/compress.test.ts @@ -1,9 +1,14 @@ -import { gzipSync } from 'node:zlib'; +import { deflateSync, gzipSync } from 'node:zlib'; import context from '@aws-lambda-powertools/testing-utils/context'; import { beforeEach, describe, expect, it } from 'vitest'; +import { streamify } from '../../../../src/http/index.js'; import { compress } from '../../../../src/http/middleware/index.js'; import { Router } from '../../../../src/http/Router.js'; -import { createSettingHeadersMiddleware, createTestEvent } from '../helpers.js'; +import { + createSettingHeadersMiddleware, + createTestEvent, + ResponseStream, +} from '../helpers.js'; describe('Compress Middleware', () => { const event = createTestEvent('/test', 'GET'); @@ -62,6 +67,24 @@ describe('Compress Middleware', () => { expect(result.isBase64Encoded).toBe(false); }); + it('skips compression when content is below threshold and content-length is not set', async () => { + // Prepare + const application = new Router(); + const smallBody = { message: 'Small' }; + + application.use(compress({ threshold: 100 })); + application.get('/test', () => { + return smallBody; + }); + + // Act + const result = await application.resolve(event, context); + + // Assess + expect(result.headers?.['content-encoding']).toBeUndefined(); + expect(result.isBase64Encoded).toBe(false); + }); + it('skips compression for HEAD requests', async () => { // Prepare const headEvent = createTestEvent('/test', 'HEAD'); @@ -104,6 +127,7 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toEqual('gzip'); expect(result.isBase64Encoded).toBe(true); + expect(result.body).toBe(gzipSync(JSON.stringify(body)).toString('base64')); }); it('skips compression when cache-control no-transform is set', async () => { @@ -155,6 +179,9 @@ describe('Compress Middleware', () => { // Assess expect(result.headers?.['content-encoding']).toBe('deflate'); expect(result.isBase64Encoded).toBe(true); + expect(result.body).toBe( + deflateSync(JSON.stringify(body)).toString('base64') + ); }); it('does not compress if Accept-Encoding is set to identity', async () => { @@ -173,4 +200,26 @@ describe('Compress Middleware', () => { expect(result.headers?.['content-encoding']).toBeUndefined(); expect(result.isBase64Encoded).toBe(false); }); + + it('skips compression and content-length in streaming mode', async () => { + // Prepare + const application = new Router(); + application.use(compress()); + application.get('/test', () => body); + + const handler = streamify(application); + const responseStream = new ResponseStream(); + + // Act + const result = await handler(event, responseStream, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.headers['content-encoding']).toBeUndefined(); + expect(result.headers['content-length']).toBeUndefined(); + expect(result.body).toBe(JSON.stringify(body)); + expect(result.body).not.toBe( + gzipSync(JSON.stringify(body)).toString('base64') + ); + }); }); From 728a44f5b8620194be5d957235803e6cb770de56 Mon Sep 17 00:00:00 2001 From: svozza Date: Sat, 29 Nov 2025 15:58:08 +0000 Subject: [PATCH 2/4] add e2e test --- .../event-handler/tests/e2e/httpRouter.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/event-handler/tests/e2e/httpRouter.test.ts b/packages/event-handler/tests/e2e/httpRouter.test.ts index cfe56b2d50..549d5ccfa2 100644 --- a/packages/event-handler/tests/e2e/httpRouter.test.ts +++ b/packages/event-handler/tests/e2e/httpRouter.test.ts @@ -525,6 +525,21 @@ describe('REST Event Handler E2E tests', () => { expect(data.data.length).toBe(200); expect(response.headers.get('content-encoding')).toBe('gzip'); }); + + it('does not compress small responses below threshold', async () => { + // Act + const response = await fetch(`${apiUrl}/compress/small`, { + headers: { 'Accept-Encoding': 'gzip' }, + }); + // Act + const data = await response.json(); + + // Assess + expect(response.status).toBe(200); + expect(data.message).toBe('Small'); + // Small response (~20 bytes) is below 100 byte threshold, should not be compressed + expect(response.headers.get('content-encoding')).toBeNull(); + }); }); describe('Request Body and Headers', () => { From f227a28e644d62cf89fae714c1f7e842ff26049e Mon Sep 17 00:00:00 2001 From: svozza Date: Sat, 29 Nov 2025 13:12:37 -0500 Subject: [PATCH 3/4] add test for compressing early return middleware --- .../unit/http/middleware/compress.test.ts | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) 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 d60a460d7b..91b1123c55 100644 --- a/packages/event-handler/tests/unit/http/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/http/middleware/compress.test.ts @@ -1,4 +1,4 @@ -import { deflateSync, gzipSync } from 'node:zlib'; +import { deflateSync, gunzipSync, gzipSync } from 'node:zlib'; import context from '@aws-lambda-powertools/testing-utils/context'; import { beforeEach, describe, expect, it } from 'vitest'; import { streamify } from '../../../../src/http/index.js'; @@ -155,6 +155,43 @@ describe('Compress Middleware', () => { expect(result.isBase64Encoded).toBe(false); }); + it('compresses early return middleware', async () => { + // Prepare + const application = new Router(); + const largeMwBody = { test: 'y'.repeat(2000) }; + + application.get( + '/test', + [ + compress({ + encoding: 'gzip', + }), + async () => { + await 10; + // return {message: 'Middleware applied'} + return largeMwBody; + }, + ], + () => { + return body; + } + ); + + // Act + const result = await application.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.isBase64Encoded).toBe(true); + expect(result.headers).toEqual({ + 'content-encoding': 'gzip', + 'content-type': 'application/json', + }); + expect( + gunzipSync(Buffer.from(result.body, 'base64')).toString('utf8') + ).toEqual(JSON.stringify(largeMwBody)); + }); + it('uses specified encoding when provided', async () => { // Prepare const application = new Router(); From ff8d135912a7d62ef6c9f23d14a9ab9c40465a8f Mon Sep 17 00:00:00 2001 From: svozza Date: Sat, 29 Nov 2025 13:21:05 -0500 Subject: [PATCH 4/4] fis sonarqube issues --- .../tests/unit/http/middleware/compress.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 91b1123c55..250f1c92fe 100644 --- a/packages/event-handler/tests/unit/http/middleware/compress.test.ts +++ b/packages/event-handler/tests/unit/http/middleware/compress.test.ts @@ -166,10 +166,8 @@ describe('Compress Middleware', () => { compress({ encoding: 'gzip', }), - async () => { - await 10; - // return {message: 'Middleware applied'} - return largeMwBody; + () => { + return Promise.resolve(largeMwBody); }, ], () => {