From 209deee8af4669d36a38fe0d7b31e5851eb96ba3 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 16 Mar 2021 15:31:30 -0400 Subject: [PATCH 1/6] Support compression based on accept-encoding header --- index.d.ts | 1 + index.js | 1 + lib/compression.js | 43 +++++++++++++++++++++++++++++++++++++++++++ lib/response.js | 14 ++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 lib/compression.js diff --git a/index.d.ts b/index.d.ts index d14cdd5..dd869dc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -103,6 +103,7 @@ export declare interface Options { version?: string; errorHeaderWhitelist?: string[]; isBase64?: boolean; + compression?: boolean; headers?: object; } diff --git a/index.js b/index.js index d2eacd1..9d3e060 100644 --- a/index.js +++ b/index.js @@ -29,6 +29,7 @@ class API { this._errorHeaderWhitelist = props && Array.isArray(props.errorHeaderWhitelist) ? props.errorHeaderWhitelist.map(header => header.toLowerCase()) : [] this._isBase64 = props && typeof props.isBase64 === 'boolean' ? props.isBase64 : false this._headers = props && props.headers && typeof props.headers === 'object' ? props.headers : {} + this._compression = props && typeof props.compression === 'boolean' ? props.compression : false // Set sampling info this._sampleCounts = {} diff --git a/lib/compression.js b/lib/compression.js new file mode 100644 index 0000000..a1fec8e --- /dev/null +++ b/lib/compression.js @@ -0,0 +1,43 @@ +'use strict' + +/** + * Lightweight web framework for your serverless applications + * @author Jeremy Daly + * @license MIT +*/ + +const zlib = require('zlib') + +exports.compress = (input,headers) => { + const acceptEncodingHeader = headers['accept-encoding'] || '' + const acceptableEncodings = new Set(acceptEncodingHeader.split(',').map(str => str.trim())) + + // Handle Brotli compression + if (acceptableEncodings.has('br')) { + return { + data: zlib.brotliCompressSync(input), + contentEncoding: 'br' + } + } + + // Handle Gzip compression + if (acceptableEncodings.has('gzip')) { + return { + data: zlib.gzipSync(input), + contentEncoding: 'gzip' + } + } + + // Handle deflate compression + if (acceptableEncodings.has('deflate')) { + return { + data: zlib.deflateSync(input), + contentEncoding: 'deflate' + } + } + + return { + data: input, + contentEncoding: null + } +} \ No newline at end of file diff --git a/lib/response.js b/lib/response.js index 55930ff..bfe2f63 100644 --- a/lib/response.js +++ b/lib/response.js @@ -10,6 +10,7 @@ const UTILS = require('./utils.js') const fs = require('fs') // Require Node.js file system const path = require('path') // Require Node.js path +const compression = require('./compression') // Require compression lib const { ResponseError, FileError } = require('./errors') // Require custom errors // Require AWS S3 service @@ -47,6 +48,9 @@ class RESPONSE { // base64 encoding flag this._isBase64 = app._isBase64 + // compression flag + this._compression = app._compression + // Default callback function this._callback = 'callback' @@ -465,6 +469,16 @@ class RESPONSE { this._request.interface === 'alb' ? { statusDescription: `${this._statusCode} ${UTILS.statusLookup(this._statusCode)}` } : {} ) + // Compress the body + if (this._compression && this._response.body) { + const { data, contentEncoding } = compression.compress(this._response.body, this._request.headers) + if (contentEncoding) { + this._isBase64 = true + this._response.body = data.toString('base64') + this._response.headers['content-encoding'] = [contentEncoding] + } + } + // Trigger the callback function this.app._callback(null, this._response, this) From f3b6e8f1bf4dd94818167d4a4525a68c033d0cbf Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 16 Mar 2021 15:38:56 -0400 Subject: [PATCH 2/6] Correctly set response base64 --- lib/response.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/response.js b/lib/response.js index bfe2f63..114c78e 100644 --- a/lib/response.js +++ b/lib/response.js @@ -473,8 +473,8 @@ class RESPONSE { if (this._compression && this._response.body) { const { data, contentEncoding } = compression.compress(this._response.body, this._request.headers) if (contentEncoding) { - this._isBase64 = true this._response.body = data.toString('base64') + this._response.isBase64Encoded = true this._response.headers['content-encoding'] = [contentEncoding] } } From 9c79aaecac69178e98fd1ec228200d8406e02928 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 16 Mar 2021 16:00:00 -0400 Subject: [PATCH 3/6] Cleanup content-encoding response header --- lib/compression.js | 2 +- lib/response.js | 9 ++++++--- test/responses.js | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/compression.js b/lib/compression.js index a1fec8e..22daa2a 100644 --- a/lib/compression.js +++ b/lib/compression.js @@ -10,7 +10,7 @@ const zlib = require('zlib') exports.compress = (input,headers) => { const acceptEncodingHeader = headers['accept-encoding'] || '' - const acceptableEncodings = new Set(acceptEncodingHeader.split(',').map(str => str.trim())) + const acceptableEncodings = new Set(acceptEncodingHeader.toLowerCase().split(',').map(str => str.trim())) // Handle Brotli compression if (acceptableEncodings.has('br')) { diff --git a/lib/response.js b/lib/response.js index 114c78e..795e72e 100644 --- a/lib/response.js +++ b/lib/response.js @@ -473,9 +473,12 @@ class RESPONSE { if (this._compression && this._response.body) { const { data, contentEncoding } = compression.compress(this._response.body, this._request.headers) if (contentEncoding) { - this._response.body = data.toString('base64') - this._response.isBase64Encoded = true - this._response.headers['content-encoding'] = [contentEncoding] + Object.assign(this._response, { body: data.toString('base64'), isBase64Encoded: true }) + if (this._response.multiValueHeaders) { + this._response.multiValueHeaders['content-encoding'] = [contentEncoding] + } else { + this._response.headers['content-encoding'] = [contentEncoding] + } } } diff --git a/test/responses.js b/test/responses.js index 37f7399..38a41dc 100644 --- a/test/responses.js +++ b/test/responses.js @@ -27,12 +27,18 @@ const api4 = require('../index')({ return gzipSync(json).toString('base64') } }) +// Init API with compression +const api5 = require('../index')({ + version: 'v1.0', + compression: true +}) let event = { httpMethod: 'get', path: '/test', body: {}, multiValueHeaders: { + 'Accept-Encoding': ['deflate, gzip'], 'Content-Type': ['application/json'] } } @@ -123,6 +129,10 @@ api4.get('/testGZIP', function(req,res) { res.json({ object: true }) }) +api5.get('/testGZIP', function(req,res) { + res.json({ object: true }) +}) + /******************************************************************************/ /*** BEGIN TESTS ***/ /******************************************************************************/ @@ -278,6 +288,12 @@ describe('Response Tests:', function() { expect(result).to.deep.equal({ multiValueHeaders: { 'content-encoding': ['gzip'], 'content-type': ['application/json'] }, statusCode: 200, body: 'H4sIAAAAAAAAE6tWyk/KSk0uUbIqKSpN1VGKTy4tLsnPhXOTEotTzUwg3FoAan86iy0AAAA=', isBase64Encoded: true }) }) // end it + it('Compression (GZIP)', async function() { + let _event = Object.assign({},event,{ path: '/testGZIP'}) + let result = await new Promise(r => api5.run(_event,{},(e,res) => { r(res) })) + expect(result).to.deep.equal({ multiValueHeaders: { 'content-encoding': ['gzip'], 'content-type': ['application/json'] }, statusCode: 200, body: 'H4sIAAAAAAAAE6tWyk/KSk0uUbIqKSpNrQUAAQd5Ug8AAAA=', isBase64Encoded: true }) + }) // end it + after(function() { stub.restore() }) From 3cf9ad5f059a775b8b6147265f385364c31fbed9 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 16 Mar 2021 16:06:49 -0400 Subject: [PATCH 4/6] Updated compression docs --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 928a7f5..02cfac1 100644 --- a/README.md +++ b/README.md @@ -1342,7 +1342,17 @@ You can also use the `cors()` ([see here](#corsoptions)) convenience method to a Conditional route support could be added via middleware or with conditional logic within the `OPTIONS` route. ## Compression -Currently, API Gateway HTTP APIs do not support automatic compression out of the box, but that doesn't mean the Lambda can't return a compressed response. In order to create a compressed response instantiate the API with `isBase64` set to true, and a custom serializer that returns a compressed response as a base64 encoded string. Also, don't forget to set the correct `content-encoding` header: +Currently, API Gateway HTTP APIs do not support automatic compression, but that doesn't mean the Lambda can't return a compressed response. Lambda API supports compression out of the box: + +```javascript +const api = require('lambda-api')({ + compression: true +}) +``` + +The response will automatically be compressed based on the `Accept-Encoding` header in the request. Supported compressions are Brotli, Gzip and Deflate - in that priority order. + +For full control over the response compression, instantiate the API with `isBase64` set to true, and a custom serializer that returns a compressed response as a base64 encoded string. Also, don't forget to set the correct `content-encoding` header: ```javascript const zlib = require('zlib') From 8a52665fd693e74aeadd62bdcf14bf6659a4581d Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 16 Mar 2021 16:17:05 -0400 Subject: [PATCH 5/6] Fix content-encoding setter for regular headers --- lib/response.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/response.js b/lib/response.js index 795e72e..0e4122c 100644 --- a/lib/response.js +++ b/lib/response.js @@ -477,7 +477,7 @@ class RESPONSE { if (this._response.multiValueHeaders) { this._response.multiValueHeaders['content-encoding'] = [contentEncoding] } else { - this._response.headers['content-encoding'] = [contentEncoding] + this._response.headers['content-encoding'] = contentEncoding } } } From a2db9774f6c8a1cbfaad3f884f297e6589a1f9a4 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 16 Mar 2021 16:22:56 -0400 Subject: [PATCH 6/6] Conditionally support brotli --- lib/compression.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/compression.js b/lib/compression.js index 22daa2a..d5508f2 100644 --- a/lib/compression.js +++ b/lib/compression.js @@ -12,8 +12,8 @@ exports.compress = (input,headers) => { const acceptEncodingHeader = headers['accept-encoding'] || '' const acceptableEncodings = new Set(acceptEncodingHeader.toLowerCase().split(',').map(str => str.trim())) - // Handle Brotli compression - if (acceptableEncodings.has('br')) { + // Handle Brotli compression (Only supported in Node v10 and later) + if (acceptableEncodings.has('br') && typeof zlib.brotliCompressSync === 'function') { return { data: zlib.brotliCompressSync(input), contentEncoding: 'br'