Skip to content

Commit 320e0dc

Browse files
sdangolsvozza
andauthored
feat(event-handler): added compress middleware for the REST API event handler (#4495)
Co-authored-by: Stefano Vozza <svozza@amazon.com>
1 parent 59fe03c commit 320e0dc

File tree

9 files changed

+502
-6
lines changed

9 files changed

+502
-6
lines changed

packages/event-handler/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@
7777
"types": "./lib/esm/rest/index.d.ts",
7878
"default": "./lib/esm/rest/index.js"
7979
}
80+
},
81+
"./experimental-rest/middleware": {
82+
"require": {
83+
"types": "./lib/cjs/rest/middleware/index.d.ts",
84+
"default": "./lib/cjs/rest/middleware/index.js"
85+
},
86+
"import": {
87+
"types": "./lib/esm/rest/middleware/index.d.ts",
88+
"default": "./lib/esm/rest/middleware/index.js"
89+
}
8090
}
8191
},
8292
"typesVersions": {

packages/event-handler/src/rest/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,15 @@ export const PARAM_PATTERN = /:([a-zA-Z_]\w*)(?=\/|$)/g;
8787
export const SAFE_CHARS = "-._~()'!*:@,;=+&$";
8888

8989
export const UNSAFE_CHARS = '%<> \\[\\]{}|^';
90+
91+
export const DEFAULT_COMPRESSION_RESPONSE_THRESHOLD = 1024;
92+
93+
export const CACHE_CONTROL_NO_TRANSFORM_REGEX =
94+
/(?:^|,)\s*?no-transform\s*?(?:,|$)/i;
95+
96+
export const COMPRESSION_ENCODING_TYPES = {
97+
GZIP: 'gzip',
98+
DEFLATE: 'deflate',
99+
IDENTITY: 'identity',
100+
ANY: '*',
101+
} as const;

packages/event-handler/src/rest/converters.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
2-
import type { HandlerResponse } from '../types/rest.js';
2+
import type { CompressionOptions, HandlerResponse } from '../types/rest.js';
3+
import { COMPRESSION_ENCODING_TYPES } from './constants.js';
34
import { isAPIGatewayProxyResult } from './utils.js';
45

56
/**
@@ -89,11 +90,34 @@ export const webResponseToProxyResult = async (
8990
}
9091
}
9192

93+
// Check if response contains compressed/binary content
94+
const contentEncoding = response.headers.get(
95+
'content-encoding'
96+
) as CompressionOptions['encoding'];
97+
let body: string;
98+
let isBase64Encoded = false;
99+
100+
if (
101+
contentEncoding &&
102+
[
103+
COMPRESSION_ENCODING_TYPES.GZIP,
104+
COMPRESSION_ENCODING_TYPES.DEFLATE,
105+
].includes(contentEncoding)
106+
) {
107+
// For compressed content, get as buffer and encode to base64
108+
const buffer = await response.arrayBuffer();
109+
body = Buffer.from(buffer).toString('base64');
110+
isBase64Encoded = true;
111+
} else {
112+
// For text content, use text()
113+
body = await response.text();
114+
}
115+
92116
const result: APIGatewayProxyResult = {
93117
statusCode: response.status,
94118
headers,
95-
body: await response.text(),
96-
isBase64Encoded: false,
119+
body,
120+
isBase64Encoded,
97121
};
98122

99123
if (Object.keys(multiValueHeaders).length > 0) {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { Middleware } from '../../types/index.js';
2+
import type { CompressionOptions } from '../../types/rest.js';
3+
import {
4+
CACHE_CONTROL_NO_TRANSFORM_REGEX,
5+
COMPRESSION_ENCODING_TYPES,
6+
DEFAULT_COMPRESSION_RESPONSE_THRESHOLD,
7+
} from '../constants.js';
8+
9+
/**
10+
* Compresses HTTP response bodies using standard compression algorithms.
11+
*
12+
* This middleware automatically compresses response bodies when they exceed
13+
* a specified threshold and the client supports compression. It respects
14+
* cache-control directives and only compresses appropriate content types.
15+
*
16+
* The middleware checks several conditions before compressing:
17+
* - Response is not already encoded or chunked
18+
* - Request method is not HEAD
19+
* - Content length exceeds the threshold
20+
* - Content type is compressible
21+
* - Cache-Control header doesn't contain no-transform
22+
* - Response has a body
23+
*
24+
* **Basic compression with default settings**
25+
*
26+
* @example
27+
* ```typescript
28+
* import { Router } from '@aws-lambda-powertools/event-handler/experimental-rest';
29+
* import { compress } from '@aws-lambda-powertools/event-handler/experimental-rest/middleware';
30+
*
31+
* const app = new Router();
32+
*
33+
* app.use(compress());
34+
*
35+
* app.get('/api/data', async () => {
36+
* return { data: 'large response body...' };
37+
* });
38+
* ```
39+
*
40+
* **Custom compression settings**
41+
*
42+
* @example
43+
* ```typescript
44+
* import { Router } from '@aws-lambda-powertools/event-handler/experimental-rest';
45+
* import { compress } from '@aws-lambda-powertools/event-handler/experimental-rest/middleware';
46+
*
47+
* const app = new Router();
48+
*
49+
* app.use(compress({
50+
* threshold: 2048,
51+
* encoding: 'deflate'
52+
* }));
53+
*
54+
* app.get('/api/large-data', async () => {
55+
* return { data: 'very large response...' };
56+
* });
57+
* ```
58+
*
59+
* @param options - Configuration options for compression behavior
60+
* @param options.threshold - Minimum response size in bytes to trigger compression (default: 1024)
61+
* @param options.encoding - Preferred compression encoding to use when client supports multiple formats
62+
*/
63+
64+
const compress = (options?: CompressionOptions): Middleware => {
65+
const preferredEncoding =
66+
options?.encoding ?? COMPRESSION_ENCODING_TYPES.GZIP;
67+
const threshold =
68+
options?.threshold ?? DEFAULT_COMPRESSION_RESPONSE_THRESHOLD;
69+
70+
return async (_, reqCtx, next) => {
71+
await next();
72+
73+
if (
74+
!shouldCompress(reqCtx.request, reqCtx.res, preferredEncoding, threshold)
75+
) {
76+
return;
77+
}
78+
79+
// Compress the response
80+
const stream = new CompressionStream(preferredEncoding);
81+
reqCtx.res = new Response(reqCtx.res.body.pipeThrough(stream), reqCtx.res);
82+
reqCtx.res.headers.delete('content-length');
83+
reqCtx.res.headers.set('content-encoding', preferredEncoding);
84+
};
85+
};
86+
87+
const shouldCompress = (
88+
request: Request,
89+
response: Response,
90+
preferredEncoding: NonNullable<CompressionOptions['encoding']>,
91+
threshold: NonNullable<CompressionOptions['threshold']>
92+
): response is Response & { body: NonNullable<Response['body']> } => {
93+
const acceptedEncoding =
94+
request.headers.get('accept-encoding') ?? COMPRESSION_ENCODING_TYPES.ANY;
95+
const contentLength = response.headers.get('content-length');
96+
const cacheControl = response.headers.get('cache-control');
97+
98+
const isEncodedOrChunked =
99+
response.headers.has('content-encoding') ||
100+
response.headers.has('transfer-encoding');
101+
102+
const shouldEncode =
103+
!acceptedEncoding.includes(COMPRESSION_ENCODING_TYPES.IDENTITY) &&
104+
(acceptedEncoding.includes(preferredEncoding) ||
105+
acceptedEncoding.includes(COMPRESSION_ENCODING_TYPES.ANY));
106+
107+
return (
108+
shouldEncode &&
109+
!isEncodedOrChunked &&
110+
request.method !== 'HEAD' &&
111+
(!contentLength || Number(contentLength) > threshold) &&
112+
(!cacheControl || !CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl)) &&
113+
response.body !== null
114+
);
115+
};
116+
117+
export { compress };
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { compress } from './compress.js';

packages/event-handler/src/types/rest.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ type ValidationResult = {
111111
issues: string[];
112112
};
113113

114+
type CompressionOptions = {
115+
encoding?: 'gzip' | 'deflate';
116+
threshold?: number;
117+
};
118+
114119
export type {
115120
CompiledRoute,
116121
DynamicRoute,
@@ -131,4 +136,5 @@ export type {
131136
RestRouteHandlerOptions,
132137
RouteRegistryOptions,
133138
ValidationResult,
139+
CompressionOptions,
134140
};

0 commit comments

Comments
 (0)