Skip to content

Commit

Permalink
feat: add brotli compression support (#20)
Browse files Browse the repository at this point in the history
* fix: always set content-encoding header if content is gzipped

* feat: add brotli compression is supported by client

* fix: brotli name and params

* fix: set lower brotli compression quality to improve speed

* fix: set brotli size hint based on input length
  • Loading branch information
mredele authored Aug 21, 2024
1 parent 092b435 commit 72e3a89
Show file tree
Hide file tree
Showing 2 changed files with 149 additions and 16 deletions.
135 changes: 124 additions & 11 deletions src/response.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,85 @@ const { Request } = require('./request')
const { gzipSync } = require('zlib')

describe('Response object', () => {
const requestObject = { a: 1 }
let req
beforeEach(() => {
const eventV2 = {
version: '2.0',
routeKey: '$default',
rawPath: '/my/path',
rawQueryString:
'a=1&b=1&b=2&c[]=-firstName&c[]=lastName&d[1]=1&d[0]=0&shoe[color]=yellow&email=test+user@gmail.com&math=1+2&&math=4+5&',

cookies: ['cookie1', 'cookie2'],
headers: {
'Content-Type': 'application/json',
'X-Header': 'value1,value2'
},
queryStringParameters: {
a: '1',
b: '2',
'c[]': 'lastName',
'd[1]': '1',
'd[0]': '0',
'shoe[color]': 'yellow',
email: 'test+user@gmail.com',
math: '1+2'
},
requestContext: {
accountId: '123456789012',
apiId: 'api-id',
authentication: {
clientCert: {
clientCertPem: 'CERT_CONTENT',
subjectDN: 'www.example.com',
issuerDN: 'Example issuer',
serialNumber: 'a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1',
validity: {
notBefore: 'May 28 12:30:02 2019 GMT',
notAfter: 'Aug 5 09:36:04 2021 GMT'
}
}
},
authorizer: {
jwt: {
claims: {
claim1: 'value1',
claim2: 'value2'
},
scopes: ['scope1', 'scope2']
}
},
domainName: 'id.execute-api.us-east-1.amazonaws.com',
domainPrefix: 'id',
http: {
method: 'POST',
path: '/my/path',
protocol: 'HTTP/1.1',
sourceIp: 'IP',
userAgent: 'agent'
},
requestId: 'id',
routeKey: '$default',
stage: '$default',
time: '12/Mar/2020:19:03:58 +0000',
timeEpoch: 1583348638390
},
body: JSON.stringify(requestObject),
pathParameters: {
parameter1: 'value1'
},
isBase64Encoded: false,
stageVariables: {
stageVariable1: 'value1',
stageVariable2: 'value2'
}
}
req = new Request(eventV2)
})

it('set response status properly', done => {
const res = new Response(null, (err, out) => {
const res = new Response(req, (err, out) => {
expect(out).toEqual({
statusCode: 404,
isBase64Encoded: false,
Expand All @@ -18,13 +95,48 @@ describe('Response object', () => {
})

it('send body properly', done => {
const res = new Response(null, (err, out) => {
const res = new Response(req, (err, out) => {
expect(out.body).toBe('hello')
done()
})
res.send('hello')
})

it('brotli compress large body if supported', done => {
const event = {
headers: {
Accept: 'text/html',
'Content-Length': 0,
'Accept-Encoding': 'gzip, deflate, br'
},
multiValueHeaders: {
Accept: ['text/html'],
'Content-Length': [0],
'Accept-Encoding': ['gzip, deflate, br']
},
httpMethod: 'POST',
isBase64Encoded: false,
path: '/path',
pathParameters: {},
queryStringParameters: {},
multiValueQueryStringParameters: {},
stageVariables: {},
requestContext: {},
resource: ''
}

const req = new Request(event)
req.next = error => {}
const res = new Response(req, (err, out) => {
expect(out.body).toBeDefined()
expect(out.body.length).toBeLessThan(10000)
expect(out.isBase64Encoded).toBeTruthy()
expect(out.headers['Content-Encoding'] === 'br')
done()
})
res.send('a'.repeat(6000000))
})

it('gzip large body', done => {
const event = {
headers: {
Expand Down Expand Up @@ -54,6 +166,7 @@ describe('Response object', () => {
expect(out.body).toBeDefined()
expect(out.body.length).toBeLessThan(10000)
expect(out.isBase64Encoded).toBeTruthy()
expect(out.headers['Content-Encoding'] === 'gzip')
done()
})
res.send('a'.repeat(6000000))
Expand Down Expand Up @@ -95,7 +208,7 @@ describe('Response object', () => {

it('already gzipped body left as is', done => {
const content = gzipSync('foo bar some text to be zippped...').toString('base64')
const res = new Response(null, (err, out) => {
const res = new Response(req, (err, out) => {
expect(out.body).toEqual(content)
expect(out.isBase64Encoded).toBeTruthy()
done()
Expand All @@ -104,7 +217,7 @@ describe('Response object', () => {
})

it('set content-type', done => {
const res = new Response(null, (err, out) => {
const res = new Response(req, (err, out) => {
expect(out.headers).toEqual({
'content-type': 'text/html'
})
Expand All @@ -115,7 +228,7 @@ describe('Response object', () => {
})

it('get header', done => {
const res = new Response(null, err => {
const res = new Response(req, err => {
done()
})
res.set('X-Header', 'a')
Expand All @@ -126,7 +239,7 @@ describe('Response object', () => {
})

it('set header with setHeader', done => {
const res = new Response(null, err => {
const res = new Response(req, err => {
done()
})
res.setHeader('X-Header', 'b')
Expand All @@ -137,7 +250,7 @@ describe('Response object', () => {
})

it('set header with header', done => {
const res = new Response(null, err => {
const res = new Response(req, err => {
done()
})
res.header('X-Header', 'c')
Expand All @@ -148,7 +261,7 @@ describe('Response object', () => {
})

it('set cookies', done => {
const res = new Response(null, (err, out) => {
const res = new Response(req, (err, out) => {
expect(out.multiValueHeaders).toEqual({
'Set-Cookie': [
'foo=1234; Path=/',
Expand All @@ -173,7 +286,7 @@ describe('Response object', () => {
})

it('can chain status method', done => {
const res = new Response(null, (err, out) => {
const res = new Response(req, (err, out) => {
expect(out.statusCode).toBe(201)
expect(res.statusCode).toBe(201)
done()
Expand All @@ -182,15 +295,15 @@ describe('Response object', () => {
})

it('can chain set method', done => {
const res = new Response(null, (err, out) => {
const res = new Response(req, (err, out) => {
expect(out.headers).toEqual({ 'x-header': 'a' })
done()
})
res.set('x-header', 'a').end()
})

it('can chain type method', done => {
const response = new Response(null, (err, out) => {
const response = new Response(req, (err, out) => {
expect(out.headers).toEqual({
'content-type': 'text/xml'
})
Expand Down
30 changes: 25 additions & 5 deletions src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
APIGatewayProxyCallbackV2,
APIGatewayProxyResult
} from 'aws-lambda'
import { gzipSync } from 'zlib'
import { brotliCompressSync, gzipSync, constants } from 'zlib'

export class FormatError extends Error {
status: number
Expand Down Expand Up @@ -62,23 +62,43 @@ export class Response extends EventEmitter {
const headers = this.expresslessResHeaders
const gzipBase64MagicBytes = 'H4s'
let isBase64Gzipped = bodyStr.startsWith(gzipBase64MagicBytes)
let isBase64BrotliCompressed = false

if (bodyStr.length > 5000000 && !isBase64Gzipped && this.req.acceptsEncodings('gzip')) {
const acceptsGzip = this.req.acceptsEncodings('gzip')
const acceptsBrotli = this.req.acceptsEncodings('br')
const needsCompression =
bodyStr.length > 5000000 && !isBase64Gzipped && (acceptsGzip || acceptsBrotli)

if (needsCompression) {
// a rough estimate if it won't fit in the 6MB Lambda response limit
// with many special characters it might be over the limit
bodyStr = gzipSync(bodyStr, { level: 9 }).toString('base64')
isBase64Gzipped = true
if (acceptsBrotli) {
bodyStr = brotliCompressSync(bodyStr, {
params: {
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
[constants.BROTLI_PARAM_SIZE_HINT]: bodyStr.length,
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY - 2
}
}).toString('base64')
isBase64BrotliCompressed = true
} else {
bodyStr = gzipSync(bodyStr, { level: 9 }).toString('base64')
isBase64Gzipped = true
}
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
}
if (isBase64Gzipped) {
headers['Content-Encoding'] = 'gzip'
} else if (isBase64BrotliCompressed) {
headers['Content-Encoding'] = 'br'
}

const apiGatewayResult: APIGatewayProxyResult = {
statusCode: this.statusCode,
headers,
isBase64Encoded: isBase64Gzipped,
isBase64Encoded: isBase64Gzipped || isBase64BrotliCompressed,
body: bodyStr
}
if (this.expresslessResMultiValueHeaders)
Expand Down

0 comments on commit 72e3a89

Please sign in to comment.