From 5ff56df9d86f793556837270be500388550dcfef Mon Sep 17 00:00:00 2001 From: "I. Hendriks" Date: Mon, 1 Aug 2022 16:14:02 +0200 Subject: [PATCH] Implemented ALB support Based on: - https://github.com/dherault/serverless-offline/pull/1250 by bayoumymac - https://github.com/tmaslen/serverless-local-alb-example by tmaslen --- src/ServerlessOffline.js | 36 ++- src/config/commandOptions.js | 4 + src/config/defaultOptions.js | 1 + src/events/alb/Alb.js | 32 ++ src/events/alb/AlbEventDefinition.js | 22 ++ src/events/alb/HttpServer.js | 287 ++++++++++++++++++ src/events/alb/index.js | 1 + .../lambda-events/LambdaAlbRequestEvent.js | 28 ++ src/events/alb/lambda-events/index.js | 1 + 9 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 src/events/alb/Alb.js create mode 100644 src/events/alb/AlbEventDefinition.js create mode 100644 src/events/alb/HttpServer.js create mode 100644 src/events/alb/index.js create mode 100644 src/events/alb/lambda-events/LambdaAlbRequestEvent.js create mode 100644 src/events/alb/lambda-events/index.js diff --git a/src/ServerlessOffline.js b/src/ServerlessOffline.js index 061e7390a..81a099bfa 100644 --- a/src/ServerlessOffline.js +++ b/src/ServerlessOffline.js @@ -23,6 +23,8 @@ export default class ServerlessOffline { #webSocket = null + #alb = null + commands = { offline: { // add start nested options @@ -61,7 +63,7 @@ export default class ServerlessOffline { async start() { this.#mergeOptions() - const { httpEvents, lambdas, scheduleEvents, webSocketEvents } = + const { httpEvents, lambdas, scheduleEvents, webSocketEvents, albEvents } = this.#getEvents() // if (lambdas.length > 0) { @@ -70,6 +72,10 @@ export default class ServerlessOffline { const eventModules = [] + if (albEvents.length > 0) { + eventModules.push(this.#createAlb(albEvents)) + } + if (httpEvents.length > 0) { eventModules.push(this.#createHttp(httpEvents)) } @@ -99,6 +105,10 @@ export default class ServerlessOffline { eventModules.push(this.#lambda.stop(SERVER_SHUTDOWN_TIMEOUT)) } + if (this.#alb) { + eventModules.push(this.#alb.stop(SERVER_SHUTDOWN_TIMEOUT)) + } + if (this.#http) { eventModules.push(this.#http.stop(SERVER_SHUTDOWN_TIMEOUT)) } @@ -210,6 +220,18 @@ export default class ServerlessOffline { return this.#webSocket.start() } + async #createAlb(events, skipStart) { + const { default: Alb } = await import('./events/alb/index.js') + + this.#alb = new Alb(this.#serverless, this.#options, this.#lambda) + + this.#alb.create(events) + + if (!skipStart) { + await this.#alb.start() + } + } + #mergeOptions() { const { service: { custom = {}, provider }, @@ -260,6 +282,7 @@ export default class ServerlessOffline { const lambdas = [] const scheduleEvents = [] const webSocketEvents = [] + const albEvents = [] const functionKeys = service.getAllFunctions() @@ -273,7 +296,7 @@ export default class ServerlessOffline { const events = service.getAllEventsInFunction(functionKey) || [] events.forEach((event) => { - const { http, httpApi, schedule, websocket } = event + const { http, httpApi, schedule, websocket, alb } = event if ((http || httpApi) && functionDefinition.handler) { const httpEvent = { @@ -353,6 +376,14 @@ export default class ServerlessOffline { websocket, }) } + + if (alb) { + albEvents.push({ + alb, + functionKey, + handler: functionDefinition.handler, + }) + } }) }) @@ -370,6 +401,7 @@ export default class ServerlessOffline { } return { + albEvents, httpEvents, lambdas, scheduleEvents, diff --git a/src/config/commandOptions.js b/src/config/commandOptions.js index a823432af..489d280a7 100644 --- a/src/config/commandOptions.js +++ b/src/config/commandOptions.js @@ -1,4 +1,8 @@ export default { + albPort: { + type: 'string', + usage: 'ALB port to listen on. Default: 3003', + }, apiKey: { type: 'string', usage: diff --git a/src/config/defaultOptions.js b/src/config/defaultOptions.js index 2eff2cfd8..639eb28d6 100644 --- a/src/config/defaultOptions.js +++ b/src/config/defaultOptions.js @@ -1,6 +1,7 @@ import { createApiKey } from '../utils/index.js' export default { + albPort: 3003, apiKey: createApiKey(), corsAllowHeaders: 'accept,content-type,x-api-key,authorization', corsAllowOrigin: '*', diff --git a/src/events/alb/Alb.js b/src/events/alb/Alb.js new file mode 100644 index 000000000..06405c522 --- /dev/null +++ b/src/events/alb/Alb.js @@ -0,0 +1,32 @@ +import AlbEventDefinition from './AlbEventDefinition.js' +import HttpServer from './HttpServer.js' + +export default class Alb { + #httpServer = null + + constructor(serverless, options, lambda) { + this.#httpServer = new HttpServer(serverless, options, lambda) + } + + start() { + return this.#httpServer.start() + } + + stop(timeout) { + return this.#httpServer.stop(timeout) + } + + #createEvent(functionKey, rawAlbEventDefinition) { + const albEvent = new AlbEventDefinition(rawAlbEventDefinition) + + this.#httpServer.createRoutes(functionKey, albEvent) + } + + create(events) { + events.forEach(({ functionKey, alb }) => { + this.#createEvent(functionKey, alb) + }) + + this.#httpServer.writeRoutesTerminal() + } +} diff --git a/src/events/alb/AlbEventDefinition.js b/src/events/alb/AlbEventDefinition.js new file mode 100644 index 000000000..1b4466c7a --- /dev/null +++ b/src/events/alb/AlbEventDefinition.js @@ -0,0 +1,22 @@ +const { assign } = Object + +export default class AlbEventDefinition { + constructor(rawAlbEventDefinition) { + let listenerArn + let priority + let conditions + let rest + + if (typeof rawAlbEventDefinition === 'string') { + ;[listenerArn, priority, conditions] = rawAlbEventDefinition.split(' ') + } else { + ;({ listenerArn, priority, conditions, ...rest } = rawAlbEventDefinition) + } + + this.listenerArn = listenerArn + this.priority = priority + this.conditions = conditions + + assign(this, rest) + } +} diff --git a/src/events/alb/HttpServer.js b/src/events/alb/HttpServer.js new file mode 100644 index 000000000..f76df7777 --- /dev/null +++ b/src/events/alb/HttpServer.js @@ -0,0 +1,287 @@ +import { exit } from 'node:process' +import { Buffer } from 'buffer' +import { Server } from '@hapi/hapi' +import { log } from '@serverless/utils/log.js' +import { detectEncoding, generateHapiPath } from '../../utils/index.js' +import LambdaAlbRequestEvent from './lambda-events/LambdaAlbRequestEvent.js' +import logRoutes from '../../utils/logRoutes.js' + +const { stringify } = JSON + +export default class HttpServer { + #lambda = null + + #options = null + + #serverless = null + + #server = null + + #lastRequestOptions = null + + #terminalInfo = [] + + constructor(serverless, options, lambda) { + this.#serverless = serverless + this.#options = options + this.#lambda = lambda + + const { host, albPort } = options + + const serverOptions = { + host, + port: albPort, + router: { + // allows for paths with trailing slashes to be the same as without + // e.g. : /my-path is the same as /my-path/ + stripTrailingSlash: true, + }, + } + + this.#server = new Server(serverOptions) + } + + async start() { + const { host, httpsProtocol, albPort } = this.#options + + try { + await this.#server.start() + } catch (err) { + log.error( + `Unexpected error while starting serverless-offline alb server on port ${albPort}:`, + err, + ) + exit(1) + } + + log.notice( + `Offline [http for alb] listening on http${ + httpsProtocol ? 's' : '' + }://${host}:${albPort}`, + ) + } + + stop(timeout) { + return this.#server.stop({ + timeout, + }) + } + + get server() { + return this.#server.listener + } + + createRoutes(functionKey, albEvent) { + const method = albEvent.conditions.method[0].toUpperCase() + const { path } = albEvent.conditions + const hapiPath = generateHapiPath(path[0], this.#options, this.#serverless) + + const stage = this.#options.stage || this.#serverless.service.provider.stage + const { host, albPort, httpsProtocol } = this.#options + const server = `${httpsProtocol ? 'https' : 'http'}://${host}:${albPort}` + + this.#terminalInfo.push({ + invokePath: `/2015-03-31/functions/${functionKey}/invocations`, + method, + path: hapiPath, + server, + stage: this.#options.noPrependStageInUrl ? null : stage, + }) + + const hapiMethod = method === 'ANY' ? '*' : method + const hapiOptions = {} + + // skip HEAD routes as hapi will fail with 'Method name not allowed: HEAD ...' + // for more details, check https://github.com/dherault/serverless-offline/issues/204 + if (hapiMethod === 'HEAD') { + log.notice( + 'HEAD method event detected. Skipping HAPI server route mapping', + ) + + return + } + + if (hapiMethod !== 'HEAD' && hapiMethod !== 'GET') { + // maxBytes: Increase request size from 1MB default limit to 10MB. + // Cf AWS API GW payload limits. + hapiOptions.payload = { + maxBytes: 1024 * 1024 * 10, + parse: false, + } + } + + const hapiHandler = async (request, h) => { + this.#lastRequestOptions = { + headers: request.headers, + method: request.method, + payload: request.payload, + url: request.url.href, + } + + // Payload processing + const encoding = detectEncoding(request) + + request.payload = request.payload && request.payload.toString(encoding) + request.rawPayload = request.payload + + // Incoming request message + log.notice() + + log.notice() + log.notice(`${method} ${request.path} (λ: ${functionKey})`) + + const response = h.response() + + const event = new LambdaAlbRequestEvent(request).create() + + const lambdaFunction = this.#lambda.get(functionKey) + + lambdaFunction.setEvent(event) + + let result + let err + + try { + result = await lambdaFunction.runHandler() + } catch (_err) { + err = _err + } + + log.debug('_____ HANDLER RESOLVED _____') + + // Failure handling + let errorStatusCode = '502' + if (err) { + // Since the --useChildProcesses option loads the handler in + // a separate process and serverless-offline communicates with it + // over IPC, we are unable to catch JavaScript unhandledException errors + // when the handler code contains bad JavaScript. Instead, we "catch" + // it here and reply in the same way that we would have above when + // we lazy-load the non-IPC handler function. + if (this.#options.useChildProcesses && err.ipcException) { + return this.#reply502( + response, + `Error while loading ${functionKey}`, + err, + ) + } + + const errorMessage = (err.message || err).toString() + + const re = /\[(\d{3})]/ + const found = errorMessage.match(re) + + if (found && found.length > 1) { + ;[, errorStatusCode] = found + } else { + errorStatusCode = '502' + } + + // Mocks Lambda errors + result = { + errorMessage, + errorType: err.constructor.name, + stackTrace: this.#getArrayStackTrace(err.stack), + } + + log.notice(`Failure: ${errorMessage}`) + + if (!this.#options.hideStackTraces) { + log.error(err.stack) + } + } + + let statusCode = 200 + if (err) { + statusCode = errorStatusCode + } + response.statusCode = statusCode + + if (typeof result === 'string') { + response.source = stringify(result) + } else if (result && typeof result.body !== 'undefined') { + if (result.isBase64Encoded) { + response.encoding = 'binary' + response.source = Buffer.from(result.body, 'base64') + response.variety = 'buffer' + } else { + if (result && result.body && typeof result.body !== 'string') { + return this.#reply502( + response, + 'According to the API Gateway specs, the body content must be stringified. Check your Lambda response and make sure you are invoking JSON.stringify(YOUR_CONTENT) on your body object', + {}, + ) + } + response.source = result.body + } + } + + // Log response + let whatToLog = result + + try { + whatToLog = stringify(result) + } catch { + // nothing + } finally { + if (this.#options.printOutput) { + log.notice( + err ? `Replying ${statusCode}` : `[${statusCode}] ${whatToLog}`, + ) + } + } + + return response + } + + this.#server.route({ + handler: hapiHandler, + method: hapiMethod, + options: hapiOptions, + path: hapiPath, + }) + } + + writeRoutesTerminal() { + logRoutes(this.#terminalInfo) + } + + #getArrayStackTrace(stack) { + if (!stack) return null + + const splittedStack = stack.split('\n') + + return splittedStack + .slice( + 0, + splittedStack.findIndex((item) => + item.match(/server.route.handler.LambdaContext/), + ), + ) + .map((line) => line.trim()) + } + + #replyError(statusCode, response, message, error) { + log.notice(message) + + log.error(error) + + response.header('Content-Type', 'application/json') + + response.statusCode = statusCode + response.source = { + errorMessage: message, + errorType: error.constructor.name, + offlineInfo: + 'If you believe this is an issue with serverless-offline please submit it, thanks. https://github.com/dherault/serverless-offline/issues', + stackTrace: this.#getArrayStackTrace(error.stack), + } + + return response + } + + #reply502(response, message, error) { + // APIG replies 502 by default on failures; + return this.#replyError(502, response, message, error) + } +} diff --git a/src/events/alb/index.js b/src/events/alb/index.js new file mode 100644 index 000000000..f5f6b5364 --- /dev/null +++ b/src/events/alb/index.js @@ -0,0 +1 @@ +export { default } from './Alb.js' diff --git a/src/events/alb/lambda-events/LambdaAlbRequestEvent.js b/src/events/alb/lambda-events/LambdaAlbRequestEvent.js new file mode 100644 index 000000000..5c9d1e515 --- /dev/null +++ b/src/events/alb/lambda-events/LambdaAlbRequestEvent.js @@ -0,0 +1,28 @@ +export default class LambdaAlbRequestEvent { + #request = null + + constructor(request) { + this.#request = request + } + + create() { + const { method } = this.#request + const httpMethod = method.toUpperCase() + + return { + body: this.#request.payload, + headers: this.#request.headers, + httpMethod, + isBase64Encoded: false, + path: this.#request.url.pathname, + queryStringParameters: this.#request.url.searchParams.toString(), + requestContext: { + elb: { + targetGroupArn: + // TODO: probably replace this + 'arn:aws:elasticloadbalancing:us-east-1:550213415212:targetgroup/5811b5d6aff964cd50efa8596604c4e0/b49d49c443aa999f', + }, + }, + } + } +} diff --git a/src/events/alb/lambda-events/index.js b/src/events/alb/lambda-events/index.js new file mode 100644 index 000000000..a267ba4b9 --- /dev/null +++ b/src/events/alb/lambda-events/index.js @@ -0,0 +1 @@ +export { default } from './LambdaAlbRequestEvent.js'