Skip to content

Commit 72e3a89

Browse files
authored
feat: add brotli compression support (#20)
* 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
1 parent 092b435 commit 72e3a89

File tree

2 files changed

+149
-16
lines changed

2 files changed

+149
-16
lines changed

src/response.spec.js

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,85 @@ const { Request } = require('./request')
33
const { gzipSync } = require('zlib')
44

55
describe('Response object', () => {
6+
const requestObject = { a: 1 }
7+
let req
8+
beforeEach(() => {
9+
const eventV2 = {
10+
version: '2.0',
11+
routeKey: '$default',
12+
rawPath: '/my/path',
13+
rawQueryString:
14+
'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&',
15+
16+
cookies: ['cookie1', 'cookie2'],
17+
headers: {
18+
'Content-Type': 'application/json',
19+
'X-Header': 'value1,value2'
20+
},
21+
queryStringParameters: {
22+
a: '1',
23+
b: '2',
24+
'c[]': 'lastName',
25+
'd[1]': '1',
26+
'd[0]': '0',
27+
'shoe[color]': 'yellow',
28+
email: 'test+user@gmail.com',
29+
math: '1+2'
30+
},
31+
requestContext: {
32+
accountId: '123456789012',
33+
apiId: 'api-id',
34+
authentication: {
35+
clientCert: {
36+
clientCertPem: 'CERT_CONTENT',
37+
subjectDN: 'www.example.com',
38+
issuerDN: 'Example issuer',
39+
serialNumber: 'a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1',
40+
validity: {
41+
notBefore: 'May 28 12:30:02 2019 GMT',
42+
notAfter: 'Aug 5 09:36:04 2021 GMT'
43+
}
44+
}
45+
},
46+
authorizer: {
47+
jwt: {
48+
claims: {
49+
claim1: 'value1',
50+
claim2: 'value2'
51+
},
52+
scopes: ['scope1', 'scope2']
53+
}
54+
},
55+
domainName: 'id.execute-api.us-east-1.amazonaws.com',
56+
domainPrefix: 'id',
57+
http: {
58+
method: 'POST',
59+
path: '/my/path',
60+
protocol: 'HTTP/1.1',
61+
sourceIp: 'IP',
62+
userAgent: 'agent'
63+
},
64+
requestId: 'id',
65+
routeKey: '$default',
66+
stage: '$default',
67+
time: '12/Mar/2020:19:03:58 +0000',
68+
timeEpoch: 1583348638390
69+
},
70+
body: JSON.stringify(requestObject),
71+
pathParameters: {
72+
parameter1: 'value1'
73+
},
74+
isBase64Encoded: false,
75+
stageVariables: {
76+
stageVariable1: 'value1',
77+
stageVariable2: 'value2'
78+
}
79+
}
80+
req = new Request(eventV2)
81+
})
82+
683
it('set response status properly', done => {
7-
const res = new Response(null, (err, out) => {
84+
const res = new Response(req, (err, out) => {
885
expect(out).toEqual({
986
statusCode: 404,
1087
isBase64Encoded: false,
@@ -18,13 +95,48 @@ describe('Response object', () => {
1895
})
1996

2097
it('send body properly', done => {
21-
const res = new Response(null, (err, out) => {
98+
const res = new Response(req, (err, out) => {
2299
expect(out.body).toBe('hello')
23100
done()
24101
})
25102
res.send('hello')
26103
})
27104

105+
it('brotli compress large body if supported', done => {
106+
const event = {
107+
headers: {
108+
Accept: 'text/html',
109+
'Content-Length': 0,
110+
'Accept-Encoding': 'gzip, deflate, br'
111+
},
112+
multiValueHeaders: {
113+
Accept: ['text/html'],
114+
'Content-Length': [0],
115+
'Accept-Encoding': ['gzip, deflate, br']
116+
},
117+
httpMethod: 'POST',
118+
isBase64Encoded: false,
119+
path: '/path',
120+
pathParameters: {},
121+
queryStringParameters: {},
122+
multiValueQueryStringParameters: {},
123+
stageVariables: {},
124+
requestContext: {},
125+
resource: ''
126+
}
127+
128+
const req = new Request(event)
129+
req.next = error => {}
130+
const res = new Response(req, (err, out) => {
131+
expect(out.body).toBeDefined()
132+
expect(out.body.length).toBeLessThan(10000)
133+
expect(out.isBase64Encoded).toBeTruthy()
134+
expect(out.headers['Content-Encoding'] === 'br')
135+
done()
136+
})
137+
res.send('a'.repeat(6000000))
138+
})
139+
28140
it('gzip large body', done => {
29141
const event = {
30142
headers: {
@@ -54,6 +166,7 @@ describe('Response object', () => {
54166
expect(out.body).toBeDefined()
55167
expect(out.body.length).toBeLessThan(10000)
56168
expect(out.isBase64Encoded).toBeTruthy()
169+
expect(out.headers['Content-Encoding'] === 'gzip')
57170
done()
58171
})
59172
res.send('a'.repeat(6000000))
@@ -95,7 +208,7 @@ describe('Response object', () => {
95208

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

106219
it('set content-type', done => {
107-
const res = new Response(null, (err, out) => {
220+
const res = new Response(req, (err, out) => {
108221
expect(out.headers).toEqual({
109222
'content-type': 'text/html'
110223
})
@@ -115,7 +228,7 @@ describe('Response object', () => {
115228
})
116229

117230
it('get header', done => {
118-
const res = new Response(null, err => {
231+
const res = new Response(req, err => {
119232
done()
120233
})
121234
res.set('X-Header', 'a')
@@ -126,7 +239,7 @@ describe('Response object', () => {
126239
})
127240

128241
it('set header with setHeader', done => {
129-
const res = new Response(null, err => {
242+
const res = new Response(req, err => {
130243
done()
131244
})
132245
res.setHeader('X-Header', 'b')
@@ -137,7 +250,7 @@ describe('Response object', () => {
137250
})
138251

139252
it('set header with header', done => {
140-
const res = new Response(null, err => {
253+
const res = new Response(req, err => {
141254
done()
142255
})
143256
res.header('X-Header', 'c')
@@ -148,7 +261,7 @@ describe('Response object', () => {
148261
})
149262

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

175288
it('can chain status method', done => {
176-
const res = new Response(null, (err, out) => {
289+
const res = new Response(req, (err, out) => {
177290
expect(out.statusCode).toBe(201)
178291
expect(res.statusCode).toBe(201)
179292
done()
@@ -182,15 +295,15 @@ describe('Response object', () => {
182295
})
183296

184297
it('can chain set method', done => {
185-
const res = new Response(null, (err, out) => {
298+
const res = new Response(req, (err, out) => {
186299
expect(out.headers).toEqual({ 'x-header': 'a' })
187300
done()
188301
})
189302
res.set('x-header', 'a').end()
190303
})
191304

192305
it('can chain type method', done => {
193-
const response = new Response(null, (err, out) => {
306+
const response = new Response(req, (err, out) => {
194307
expect(out.headers).toEqual({
195308
'content-type': 'text/xml'
196309
})

src/response.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
APIGatewayProxyCallbackV2,
66
APIGatewayProxyResult
77
} from 'aws-lambda'
8-
import { gzipSync } from 'zlib'
8+
import { brotliCompressSync, gzipSync, constants } from 'zlib'
99

1010
export class FormatError extends Error {
1111
status: number
@@ -62,23 +62,43 @@ export class Response extends EventEmitter {
6262
const headers = this.expresslessResHeaders
6363
const gzipBase64MagicBytes = 'H4s'
6464
let isBase64Gzipped = bodyStr.startsWith(gzipBase64MagicBytes)
65+
let isBase64BrotliCompressed = false
6566

66-
if (bodyStr.length > 5000000 && !isBase64Gzipped && this.req.acceptsEncodings('gzip')) {
67+
const acceptsGzip = this.req.acceptsEncodings('gzip')
68+
const acceptsBrotli = this.req.acceptsEncodings('br')
69+
const needsCompression =
70+
bodyStr.length > 5000000 && !isBase64Gzipped && (acceptsGzip || acceptsBrotli)
71+
72+
if (needsCompression) {
6773
// a rough estimate if it won't fit in the 6MB Lambda response limit
6874
// with many special characters it might be over the limit
69-
bodyStr = gzipSync(bodyStr, { level: 9 }).toString('base64')
70-
isBase64Gzipped = true
75+
if (acceptsBrotli) {
76+
bodyStr = brotliCompressSync(bodyStr, {
77+
params: {
78+
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
79+
[constants.BROTLI_PARAM_SIZE_HINT]: bodyStr.length,
80+
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY - 2
81+
}
82+
}).toString('base64')
83+
isBase64BrotliCompressed = true
84+
} else {
85+
bodyStr = gzipSync(bodyStr, { level: 9 }).toString('base64')
86+
isBase64Gzipped = true
87+
}
7188
if (!headers['Content-Type']) {
7289
headers['Content-Type'] = 'application/json'
7390
}
7491
}
7592
if (isBase64Gzipped) {
7693
headers['Content-Encoding'] = 'gzip'
94+
} else if (isBase64BrotliCompressed) {
95+
headers['Content-Encoding'] = 'br'
7796
}
97+
7898
const apiGatewayResult: APIGatewayProxyResult = {
7999
statusCode: this.statusCode,
80100
headers,
81-
isBase64Encoded: isBase64Gzipped,
101+
isBase64Encoded: isBase64Gzipped || isBase64BrotliCompressed,
82102
body: bodyStr
83103
}
84104
if (this.expresslessResMultiValueHeaders)

0 commit comments

Comments
 (0)