Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic Compression #178

Merged
merged 7 commits into from
Mar 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export declare interface Options {
version?: string;
errorHeaderWhitelist?: string[];
isBase64?: boolean;
compression?: boolean;
headers?: object;
}

Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
43 changes: 43 additions & 0 deletions lib/compression.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict'

/**
* Lightweight web framework for your serverless applications
* @author Jeremy Daly <jeremy@jeremydaly.com>
* @license MIT
*/

const zlib = require('zlib')

exports.compress = (input,headers) => {
const acceptEncodingHeader = headers['accept-encoding'] || ''
const acceptableEncodings = new Set(acceptEncodingHeader.toLowerCase().split(',').map(str => str.trim()))

// 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'
}
}

// 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
}
}
17 changes: 17 additions & 0 deletions lib/response.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -465,6 +469,19 @@ 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) {
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
}
}
}

// Trigger the callback function
this.app._callback(null, this._response, this)

Expand Down
16 changes: 16 additions & 0 deletions test/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}
}
Expand Down Expand Up @@ -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 ***/
/******************************************************************************/
Expand Down Expand Up @@ -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()
})
Expand Down