UNPKG

serverless-offline

Version:

Emulate AWS λ and API Gateway locally when developing your Serverless project

400 lines (322 loc) 11.2 kB
import { Buffer } from "node:buffer" import { exit } from "node:process" import { Server } from "@hapi/hapi" import { log } from "../../utils/log.js" import { detectEncoding, generateAlbHapiPath, getHttpApiCorsConfig, } from "../../utils/index.js" import LambdaAlbRequestEvent from "./lambda-events/LambdaAlbRequestEvent.js" import logRoutes from "../../utils/logRoutes.js" const { stringify } = JSON const { entries } = Object export default class HttpServer { #lambda = null #options = null #serverless = null #server = null #terminalInfo = [] constructor(serverless, options, lambda) { this.#serverless = serverless this.#options = options this.#lambda = lambda } async createServer() { const { host, albPort } = this.#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) this.#server.ext("onPreResponse", (request, h) => { if (request.headers.origin) { const response = request.response.isBoom ? request.response.output : request.response const explicitlySetHeaders = { ...response.headers, } if ( this.#serverless.service.provider.httpApi && this.#serverless.service.provider.httpApi.cors ) { const httpApiCors = getHttpApiCorsConfig( this.#serverless.service.provider.httpApi.cors, this, ) if (request.method === "options") { response.statusCode = 204 const allowAllOrigins = httpApiCors.allowedOrigins.length === 1 && httpApiCors.allowedOrigins[0] === "*" if ( !allowAllOrigins && !httpApiCors.allowedOrigins.includes(request.headers.origin) ) { return h.continue } } response.headers["access-control-allow-origin"] = request.headers.origin if (httpApiCors.allowCredentials) { response.headers["access-control-allow-credentials"] = "true" } if (httpApiCors.maxAge) { response.headers["access-control-max-age"] = httpApiCors.maxAge } if (httpApiCors.exposedResponseHeaders) { response.headers["access-control-expose-headers"] = httpApiCors.exposedResponseHeaders.join(",") } if (httpApiCors.allowedMethods) { response.headers["access-control-allow-methods"] = httpApiCors.allowedMethods.join(",") } if (httpApiCors.allowedHeaders) { response.headers["access-control-allow-headers"] = httpApiCors.allowedHeaders.join(",") } } else { response.headers["access-control-allow-origin"] = request.headers.origin response.headers["access-control-allow-credentials"] = "true" if (request.method === "options") { response.statusCode = 200 response.headers["access-control-expose-headers"] = request.headers["access-control-expose-headers"] || "content-type, content-length, etag" response.headers["access-control-max-age"] = 60 * 10 if (request.headers["access-control-request-headers"]) { response.headers["access-control-allow-headers"] = request.headers["access-control-request-headers"] } if (request.headers["access-control-request-method"]) { response.headers["access-control-allow-methods"] = request.headers["access-control-request-method"] } } // Override default headers with headers that have been explicitly set entries(explicitlySetHeaders).forEach(([key, value]) => { if (value) { response.headers[key] = value } }) } } return h.continue }) } async start() { const { albPort, host, httpsProtocol } = 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) } // TODO move the following block const server = `${httpsProtocol ? "https" : "http"}://${host}:${albPort}` log.notice(`ALB Server ready: ${server} 🚀`) } stop(timeout) { return this.#server.stop({ timeout, }) } get server() { return this.#server.listener } #createHapiHandler(params) { const { functionKey, method, stage } = params return async (request, h) => { const requestPath = this.#options.noPrependStageInUrl ? request.path : request.path.substr(`/${stage}`.length) // 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() let event = {} try { event = new LambdaAlbRequestEvent(request, stage, requestPath).create() } catch (err) { return this.#reply502(response, ``, err) } log.debug("event:", event) 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) { const errorMessage = (err.message || err).toString() const found = errorMessage.match(/\[(\d{3})]/) 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.error(errorMessage) } let statusCode = 200 if (result && !result.errorType) { statusCode = result.statusCode || 200 } else if (err) { statusCode = errorStatusCode || 502 } else { statusCode = 502 } response.statusCode = statusCode const headers = {} if (result && result.headers) { entries(result.headers).forEach(([headerKey, headerValue]) => { headers[headerKey] = (headers[headerKey] || []).concat(headerValue) }) } if (result && result.multiValueHeaders) { entries(result.multiValueHeaders).forEach( ([headerKey, headerValue]) => { headers[headerKey] = (headers[headerKey] || []).concat(headerValue) }, ) } log.debug("headers:", headers) response.header("Content-Type", "application/json", { duplicate: false, override: false, }) response.headers = headers if (typeof result === "string") { response.source = stringify(result) } else if (result && 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") { // FIXME TODO we should probably just write to console instead of returning a payload 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 } } createRoutes(functionKey, albEvent) { let method = "ANY" if ((albEvent.conditions.method || []).length > 0) { method = albEvent.conditions.method[0].toUpperCase() } const path = albEvent.conditions.path[0] const hapiPath = generateAlbHapiPath(path, 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 = { response: { emptyStatusCode: 200, }, } // 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 = this.#createHapiHandler({ functionKey, method, stage, }) this.#server.route({ handler: hapiHandler, method: hapiMethod, options: hapiOptions, path: hapiPath, }) } #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) } #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()) } writeRoutesTerminal() { logRoutes(this.#terminalInfo) } }