diff --git a/package.json b/package.json index 7e4c3f99b..bd63c34cd 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,8 @@ "Vasiliy Solovey (https://github.com/miltador)", "Dima Krutolianov (https://github.com/dimadk24)", "Bryan Vaz (https://github.com/bryanvaz)", - "Justin Ng (https://github.com/njyjn)" + "Justin Ng (https://github.com/njyjn)", + "Ahmed Bayoumy (https://github.com/bayoumymac)" ], "engines": { "node": ">=10.13.0" diff --git a/src/ServerlessOffline.js b/src/ServerlessOffline.js index 914e4afcf..68d50c44b 100644 --- a/src/ServerlessOffline.js +++ b/src/ServerlessOffline.js @@ -17,6 +17,7 @@ export default class ServerlessOffline { #schedule = null #webSocket = null #lambda = null + #alb = null #serverless = null constructor(serverless, cliOptions) { @@ -73,6 +74,7 @@ export default class ServerlessOffline { lambdas, scheduleEvents, webSocketEvents, + albEvents, } = this._getEvents() // if (lambdas.length > 0) { @@ -93,6 +95,10 @@ export default class ServerlessOffline { eventModules.push(this._createWebSocket(webSocketEvents)) } + if (albEvents.length > 0) { + eventModules.push(this._createAlb(albEvents)) + } + await Promise.all(eventModules) } @@ -129,6 +135,10 @@ export default class ServerlessOffline { eventModules.push(this.#webSocket.stop(SERVER_SHUTDOWN_TIMEOUT)) } + if (this.#alb) { + eventModules.push(this.#alb.stop(SERVER_SHUTDOWN_TIMEOUT)) + } + await Promise.all(eventModules) if (!skipExit) { @@ -196,6 +206,18 @@ export default class ServerlessOffline { } } + 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() + } + } + async _createSchedule(events) { const { default: Schedule } = await import('./events/schedule/index.js') @@ -269,6 +291,7 @@ export default class ServerlessOffline { const lambdas = [] const scheduleEvents = [] const webSocketEvents = [] + const albEvents = [] const functionKeys = service.getAllFunctions() @@ -282,7 +305,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 = { @@ -348,6 +371,14 @@ export default class ServerlessOffline { websocket, }) } + + if (alb) { + albEvents.push({ + functionKey, + handler: functionDefinition.handler, + alb, + }) + } }) }) @@ -369,6 +400,7 @@ export default class ServerlessOffline { lambdas, scheduleEvents, webSocketEvents, + albEvents, } } diff --git a/src/events/alb/Alb.js b/src/events/alb/Alb.js new file mode 100644 index 000000000..20a0b2e36 --- /dev/null +++ b/src/events/alb/Alb.js @@ -0,0 +1,33 @@ +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() + } + + // stops the server + stop(timeout) { + return this.#httpServer.stop(timeout) + } + + _create(functionKey, rawALBEventDefinition, handler) { + const httpEvent = new AlbEventDefinition(rawALBEventDefinition) + + this.#httpServer.createRoutes(functionKey, httpEvent, handler) + } + + create(events) { + events.forEach(({ functionKey, handler, alb }) => { + this._create(functionKey, alb, handler) + }) + + 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..419aca92a --- /dev/null +++ b/src/events/alb/HttpServer.js @@ -0,0 +1,237 @@ +import { Buffer } from 'buffer' +import { Server } from '@hapi/hapi' +import serverlessLog, { logRoutes } from '../../serverlessLog.js' +import { generateHapiPath, detectEncoding } from '../../utils/index.js' +import LambdaALBRequestEvent from './lambda-events/LambdaALBRequestEvent.js' +import debugLog from '../../debugLog.js' + +export default class HttpServer { + #lambda = null + #server = null + #lastRequestOptions = null + #options = null + #serverless = null + #terminalInfo = [] + + constructor(serverless, options, lambda) { + this.#lambda = lambda + this.#options = options + this.#serverless = serverless + + const { host, httpPort } = options + + // Hapijs server creation + this.#server = new Server({ + host, + port: httpPort, + }) + } + + async start() { + const { host, httpPort } = this.#options + + try { + await this.#server.start() + } catch (err) { + console.error( + `Unexpected error while starting serverless-offline server on port ${httpPort}:`, + err, + ) + process.exit(1) + } + + // TODO move the following block + const server = `http://${host}:${httpPort}` + + serverlessLog(`[HTTP] server ready: ${server} 🚀`) + serverlessLog('') + serverlessLog('Enter "rp" to replay the last request') + } + + // stops the server + stop(timeout) { + return this.#server.stop({ + timeout, + }) + } + + 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, httpPort } = this.#options + const server = `http://${host}:${httpPort}` + + this.#terminalInfo.push({ + method, + path: hapiPath, + server, + stage: this.#options.noPrependStageInUrl ? null : stage, + invokePath: `/2015-03-31/functions/${functionKey}/invocations`, + }) + + 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') { + serverlessLog( + '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) => { + // Here we go + // Store current request as the last one + 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) + + // Incomming request message + this._printBlankLine() + serverlessLog(`${method} ${request.path} (λ: ${functionKey})`) + + const event = new LambdaALBRequestEvent(request).create() + + debugLog('event:', event) + + const lambdaFunction = this.#lambda.get(functionKey) + + lambdaFunction.setEvent(event) + + let result + let err + + try { + result = await lambdaFunction.runHandler() + } catch (_err) { + err = _err + } + + const response = h.response() + + // 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), + } + + serverlessLog(`Failure: ${errorMessage}`) + + if (!this.#options.hideStackTraces) { + console.error(err.stack) + } + } + + let statusCode = 200 + if (err) { + statusCode = errorStatusCode + } + response.statusCode = statusCode + + if (typeof result === 'string') { + response.source = JSON.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 + } + } + + return response + } + + this.#server.route({ + handler: hapiHandler, + method: hapiMethod, + options: hapiOptions, + path: hapiPath, + }) + } + + writeRoutesTerminal() { + logRoutes(this.#terminalInfo) + } + + _printBlankLine() { + if (process.env.NODE_ENV !== 'test') { + console.log() + } + } + + _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()) + } +} 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..7788b8f91 --- /dev/null +++ b/src/events/alb/lambda-events/LambdaALBRequestEvent.js @@ -0,0 +1,27 @@ +export default class LambdaALBRequestEvent { + #routeKey = null + #request = null + + constructor(request) { + this.#request = request + } + + create() { + const { method } = this.#request + const httpMethod = method.toUpperCase() + return { + requestContext: { + elb: { + targetGroupArn: + 'arn:aws:elasticloadbalancing:us-east-1:550213415212:targetgroup/5811b5d6aff964cd50efa8596604c4e0/b49d49c443aa999f', + }, + }, + httpMethod, + path: this.#request.url.pathname, + queryStringParameters: this.#request.url.searchParams.toString(), + headers: this.#request.headers, + body: this.#request.payload, + isBase64Encoded: false, + } + } +}