Skip to content
3 changes: 3 additions & 0 deletions packages/event-handler/src/http/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -207,6 +209,7 @@ const HttpStatusText: Record<number, string> = {
export {
CACHE_CONTROL_NO_TRANSFORM_REGEX,
COMPRESSION_ENCODING_TYPES,
DECIMAL_QVALUE_REGEX,
DEFAULT_COMPRESSION_RESPONSE_THRESHOLD,
DEFAULT_CORS_OPTIONS,
HttpStatusCodes,
Expand Down
74 changes: 68 additions & 6 deletions packages/event-handler/src/http/middleware/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -90,25 +91,86 @@ 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<CompressionOptions['encoding']>
): 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<CompressionOptions['encoding']>,
threshold: NonNullable<CompressionOptions['threshold']>
): response is Response & { body: NonNullable<Response['body']> } => {
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');

const isEncodedOrChunked =
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 &&
Expand Down
157 changes: 157 additions & 0 deletions packages/event-handler/tests/unit/http/middleware/compress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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);
});
Expand Down
Loading