Skip to content
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
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 protected]&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: '[email protected]',
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