@fastify/aws-lambda
Version:
Inspired by aws-serverless-express to work with Fastify with inject functionality.
304 lines (268 loc) • 13.7 kB
JavaScript
const crypto = require('node:crypto')
const isCompressedDefault = (res) => {
const contentEncoding = res.headers['content-encoding'] || res.headers['Content-Encoding']
return contentEncoding && contentEncoding !== 'identity'
}
const customBinaryCheck = (options, res) => {
const enforceBase64 = typeof options.enforceBase64 === 'function' ? options.enforceBase64 : isCompressedDefault
return enforceBase64(res) === true
}
const disableBase64EncodingDefault = (options) => {
if (options.payloadAsStream) {
return (event) => !event.requestContext?.elb
}
return (_event) => false
}
module.exports = (app, options) => {
options = options || {}
options.binaryMimeTypes = options.binaryMimeTypes || []
options.serializeLambdaArguments = options.serializeLambdaArguments !== undefined ? options.serializeLambdaArguments : false
options.decorateRequest = options.decorateRequest !== undefined ? options.decorateRequest : true
options.retainStage = options.retainStage !== undefined ? options.retainStage : false
options.pathParameterUsedAsPath = options.pathParameterUsedAsPath !== undefined ? options.pathParameterUsedAsPath : false
options.parseCommaSeparatedQueryParams = options.parseCommaSeparatedQueryParams !== undefined ? options.parseCommaSeparatedQueryParams : true
options.payloadAsStream = options.payloadAsStream !== undefined ? options.payloadAsStream : false
options.albMultiValueHeaders = options.albMultiValueHeaders !== undefined ? options.albMultiValueHeaders : false
if (options.disableBase64Encoding === undefined) {
options.disableBase64Encoding = disableBase64EncodingDefault(options)
} else {
const opt = options.disableBase64Encoding
options.disableBase64Encoding = (_event) => opt
}
// Symbol for request-local storage on the Fastify Request instance
const AWS_ARGS = Symbol('awsLambdaArgs')
// Map to temporarily hold event/context per-invocation when serializeLambdaArguments is false.
// Keys are unique tokens passed via a header per inject call.
const awsRequestMap = new Map()
if (options.decorateRequest) {
options.decorationPropertyName = options.decorationPropertyName || 'awsLambda'
// Lazy getter that closes over the Fastify request instance (req).
app.decorateRequest(options.decorationPropertyName, {
getter: function () {
const req = this
// fast path: already attached to the request instance
if (req[AWS_ARGS]) {
const store = req[AWS_ARGS]
return {
get event () { return store.event },
get context () { return store.context }
}
}
try {
const headers = req.headers || {}
// 1) serialized-header path (serializeLambdaArguments === true)
const evHeader = headers['x-apigateway-event'] || headers['X-APIGATEWAY-EVENT']
const ctxHeader = headers['x-apigateway-context'] || headers['X-APIGATEWAY-CONTEXT']
if (evHeader) {
// decode then parse (fall back to raw JSON if decodeURIComponent fails)
let evt
try {
evt = JSON.parse(decodeURIComponent(evHeader))
} catch (e) {
evt = undefined
}
let ctx
if (ctxHeader) {
try {
ctx = JSON.parse(decodeURIComponent(ctxHeader))
} catch (e) {
ctx = undefined
}
}
req[AWS_ARGS] = { event: evt, context: ctx }
const store = req[AWS_ARGS]
return {
get event () { return store.event },
get context () { return store.context }
}
}
// 2) token-map path (non-serialized flow)
const token = headers['x-aws-lambda-fastify-request']
if (token) {
const storeFromMap = awsRequestMap.get(token)
if (storeFromMap) {
// attach to request and remove mapping + token header
req[AWS_ARGS] = { event: storeFromMap.event, context: storeFromMap.context }
awsRequestMap.delete(token)
// remove token header so it is not visible to user code
delete headers['x-aws-lambda-fastify-request']
const store = req[AWS_ARGS]
return {
get event () { return store.event },
get context () { return store.context }
}
}
}
} catch (err) {
// swallow parsing errors; fall through to undefined getters
}
// nothing found — return getters that yield undefined
return {
get event () { return undefined },
get context () { return undefined }
}
}
})
}
return function (event, context) {
const callback = arguments[2] // https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/issues/137
if (options.callbackWaitsForEmptyEventLoop !== undefined) {
context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop
}
// event.body = event.body || '' // do not magically default body to ''
// Build method/url, query, headers, payload as before
const method = event.httpMethod || (event.requestContext && event.requestContext.http ? event.requestContext.http.method : undefined)
let url = (options.pathParameterUsedAsPath && event.pathParameters && event.pathParameters[options.pathParameterUsedAsPath] && `/${event.pathParameters[options.pathParameterUsedAsPath]}`) || event.path || event.rawPath || '/' // seen rawPath for HTTP-API
// NOTE: if used directly via API Gateway domain and /stage
if (!options.retainStage && event.requestContext && event.requestContext.stage &&
event.requestContext.resourcePath && (url).indexOf(`/${event.requestContext.stage}/`) === 0 &&
event.requestContext.resourcePath.indexOf(`/${event.requestContext.stage}/`) !== 0) {
url = url.substring(event.requestContext.stage.length + 1)
}
const query = {}
const parsedCommaSeparatedQuery = {}
if (event.requestContext && event.requestContext.elb) {
if (event.multiValueQueryStringParameters) {
Object.keys(event.multiValueQueryStringParameters).forEach((q) => {
query[decodeURIComponent(q)] = event.multiValueQueryStringParameters[q].map((val) => decodeURIComponent(val))
})
} else if (event.queryStringParameters) {
Object.keys(event.queryStringParameters).forEach((q) => {
query[decodeURIComponent(q)] = decodeURIComponent(event.queryStringParameters[q])
if (options.parseCommaSeparatedQueryParams && event.version === '2.0' && typeof query[decodeURIComponent(q)] === 'string' && query[decodeURIComponent(q)].indexOf(',') > 0) {
parsedCommaSeparatedQuery[decodeURIComponent(q)] = query[decodeURIComponent(q)].split(',')
}
})
}
} else {
if (event.queryStringParameters && options.parseCommaSeparatedQueryParams && event.version === '2.0') {
Object.keys(event.queryStringParameters).forEach((k) => {
if (typeof event.queryStringParameters[k] === 'string' && event.queryStringParameters[k].indexOf(',') > 0) {
parsedCommaSeparatedQuery[decodeURIComponent(k)] = event.queryStringParameters[k].split(',')
}
})
}
Object.assign(query, event.multiValueQueryStringParameters || event.queryStringParameters, parsedCommaSeparatedQuery)
}
const headers = Object.assign({}, event.headers)
if (event.multiValueHeaders) {
Object.keys(event.multiValueHeaders).forEach((h) => {
if (event.multiValueHeaders[h].length > 1) {
headers[h] = event.multiValueHeaders[h]
} else if (event.multiValueHeaders[h].length === 1 && options.albMultiValueHeaders) {
headers[h] = event.multiValueHeaders[h][0]
}
})
}
const payload = event.body !== null && event.body !== undefined ? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8') : event.body
// NOTE: API Gateway is not setting Content-Length header on requests even when they have a body
if (event.body && !headers['Content-Length'] && !headers['content-length']) headers['content-length'] = Buffer.byteLength(payload)
// preserve the original serializeLambdaArguments behaviour
if (options.serializeLambdaArguments) {
if (event.body) event.body = undefined // remove body from event only when setting request headers
headers['x-apigateway-event'] = encodeURIComponent(JSON.stringify(event))
if (context) headers['x-apigateway-context'] = encodeURIComponent(JSON.stringify(context))
}
// For decorateRequest + non-serialized path, create token + map entry
let tokenForThisInvocation
if (options.decorateRequest && !options.serializeLambdaArguments) {
tokenForThisInvocation = crypto.randomBytes(12).toString('hex')
headers['x-aws-lambda-fastify-request'] = tokenForThisInvocation
awsRequestMap.set(tokenForThisInvocation, { event, context })
}
if (event.requestContext && event.requestContext.requestId) {
headers['x-request-id'] = headers['x-request-id'] || event.requestContext.requestId
}
// API gateway v2 cookies: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
if (event.cookies && event.cookies.length) {
headers.cookie = event.cookies.join(';')
}
let remoteAddress
if (event.requestContext) {
if (event.requestContext.http && event.requestContext.http.sourceIp) {
remoteAddress = event.requestContext.http.sourceIp
} else if (event.requestContext.identity && event.requestContext.identity.sourceIp) {
remoteAddress = event.requestContext.identity.sourceIp
}
}
const prom = new Promise((resolve) => {
app.inject({ method, url, query, payload, headers, remoteAddress, payloadAsStream: options.payloadAsStream }, (err, res) => {
// cleanup mapping if still present (safe to call even if getter already removed it)
if (tokenForThisInvocation) awsRequestMap.delete(tokenForThisInvocation)
if (err) {
console.error(err)
if (!options.payloadAsStream) {
return resolve({
statusCode: 500,
body: '',
headers: {}
})
}
const stream = res && res.stream()
return resolve({
meta: {
statusCode: 500,
headers: {}
},
// fix issue with Lambda where streaming repsonses always require a body to be present
stream: stream && stream.readableLength > 0 ? stream : require('node:stream').Readable.from('')
})
}
// chunked transfer not currently supported by API Gateway
if (headers['transfer-encoding'] === 'chunked') delete headers['transfer-encoding']
if (headers['Transfer-Encoding'] === 'chunked') delete headers['Transfer-Encoding']
let multiValueHeaders
let cookies
Object.keys(res.headers).forEach((h) => {
const isSetCookie = h.toLowerCase() === 'set-cookie'
const isArraycookie = Array.isArray(res.headers[h])
if (isArraycookie) {
if (isSetCookie) {
multiValueHeaders = multiValueHeaders || {}
multiValueHeaders[h] = res.headers[h]
} else res.headers[h] = res.headers[h].join(',')
} else if (typeof res.headers[h] !== 'undefined' && typeof res.headers[h] !== 'string') {
// NOTE: API Gateway (i.e. HttpApi) validates all headers to be a string
res.headers[h] = res.headers[h].toString()
}
if (isSetCookie) {
cookies = isArraycookie ? res.headers[h] : [res.headers[h]]
if (event.version === '2.0' || isArraycookie) delete res.headers[h]
}
})
const isBase64Disabled = options.disableBase64Encoding(event)
const contentType = (res.headers['content-type'] || res.headers['Content-Type'] || '').split(';', 1)[0]
const shouldBase64Encode = !isBase64Disabled && (options.binaryMimeTypes.indexOf(contentType) > -1 || customBinaryCheck(options, res))
const ret = {
statusCode: res.statusCode,
headers: res.headers
}
if (!isBase64Disabled) ret.isBase64Encoded = shouldBase64Encode
if (cookies && event.version === '2.0') ret.cookies = cookies
if (multiValueHeaders && (!event.version || event.version === '1.0')) ret.multiValueHeaders = multiValueHeaders
if (options.albMultiValueHeaders) {
if (!ret.multiValueHeaders) ret.multiValueHeaders = {}
Object.entries(ret.headers).forEach(([key, value]) => {
ret.multiValueHeaders[key] = [value]
})
}
if (!options.payloadAsStream) {
ret.body = shouldBase64Encode ? res.rawPayload.toString('base64') : res.payload
return resolve(ret)
}
const stream = res.stream()
resolve({
meta: ret,
// fix issue with Lambda where streaming repsonses always require a body to be present
stream: stream && stream.readableLength > 0 ? stream : require('node:stream').Readable.from('')
})
})
})
if (typeof callback !== 'function') return prom
prom.then((ret) => callback(null, ret)).catch(callback)
return prom
}
}
module.exports.default = module.exports
module.exports.awsLambdaFastify = module.exports