From 44329729473f284f4c047cb33a97215c937993ea Mon Sep 17 00:00:00 2001 From: Inqnuam Date: Wed, 3 May 2023 09:14:49 +0200 Subject: [PATCH] feat: added support for Lambda Stream Response fix: various fixes and performance improvements --- .npmignore | 1 + README.md | 2 +- build.mjs | 14 +- package.json | 22 +- resources/defineConfig.md | 15 + resources/esbuild.md | 1 + resources/express.md | 41 +- src/config.ts | 7 +- src/defineConfig.ts | 32 +- src/index.ts | 194 +++--- src/lambda/express/request.ts | 47 +- src/lambda/express/response.ts | 37 +- src/lambda/router.ts | 7 + src/lib/esbuild/buildOptimizer.ts | 23 +- src/lib/esbuild/mergeEsbuildConfig.ts | 3 + src/lib/esbuild/parseCustomEsbuild.ts | 4 + src/lib/parseEvents/documentDb.ts | 22 + src/lib/parseEvents/endpoints.ts | 122 +++- src/lib/parseEvents/funcUrl.ts | 17 + src/lib/parseEvents/index.ts | 10 +- src/lib/runtime/awslambda.ts | 21 + src/lib/runtime/bufferedStreamResponse.ts | 134 ++++ .../runtime/{lambdaMock.ts => rapidApi.ts} | 178 ++++-- .../runtime/{worker.ts => runners/node.ts} | 164 +++-- src/lib/runtime/streamResponse.ts | 188 ++++++ src/lib/server/daemon.ts | 577 ++---------------- src/lib/server/handlers.ts | 168 +++-- src/lib/utils/amazonifyHeaders.ts | 40 ++ src/lib/utils/checkHeaders.ts | 45 -- src/lib/utils/colorize.ts | 34 +- src/lib/utils/readDefineConfig.ts | 40 ++ src/lib/utils/zip.ts | 7 +- src/plugins/lambda/defaultServer/index.ts | 91 +++ src/plugins/lambda/events/alb.ts | 251 ++++++++ src/plugins/lambda/events/apg.ts | 469 ++++++++++++++ src/plugins/lambda/events/common.ts | 108 ++++ src/plugins/lambda/functionUrlInvoke.ts | 202 ++++++ .../utils => plugins/lambda}/htmlStatusMsg.ts | 2 +- src/plugins/lambda/index.ts | 146 +---- src/plugins/lambda/invokeRequests.ts | 98 +++ src/plugins/lambda/responseStreamingInvoke.ts | 59 ++ src/plugins/lambda/streamEncoder.ts | 94 +++ src/plugins/lambda/utils.ts | 190 ++++++ yarn.lock | 365 ++++++----- 44 files changed, 3097 insertions(+), 1195 deletions(-) create mode 100644 src/lib/parseEvents/documentDb.ts create mode 100644 src/lib/parseEvents/funcUrl.ts create mode 100644 src/lib/runtime/awslambda.ts create mode 100644 src/lib/runtime/bufferedStreamResponse.ts rename src/lib/runtime/{lambdaMock.ts => rapidApi.ts} (58%) rename src/lib/runtime/{worker.ts => runners/node.ts} (50%) create mode 100644 src/lib/runtime/streamResponse.ts create mode 100644 src/lib/utils/amazonifyHeaders.ts delete mode 100644 src/lib/utils/checkHeaders.ts create mode 100644 src/lib/utils/readDefineConfig.ts create mode 100644 src/plugins/lambda/defaultServer/index.ts create mode 100644 src/plugins/lambda/events/alb.ts create mode 100644 src/plugins/lambda/events/apg.ts create mode 100644 src/plugins/lambda/events/common.ts create mode 100644 src/plugins/lambda/functionUrlInvoke.ts rename src/{lib/utils => plugins/lambda}/htmlStatusMsg.ts (95%) create mode 100644 src/plugins/lambda/invokeRequests.ts create mode 100644 src/plugins/lambda/responseStreamingInvoke.ts create mode 100644 src/plugins/lambda/streamEncoder.ts create mode 100644 src/plugins/lambda/utils.ts diff --git a/.npmignore b/.npmignore index 835edf9..17ea897 100644 --- a/.npmignore +++ b/.npmignore @@ -7,6 +7,7 @@ node_modules yarn-error.log yarn.lock src +!src/lib/runtime/awslambda.ts build.mjs TODO.md tsconfig.json \ No newline at end of file diff --git a/README.md b/README.md index 82985a8..3f823d8 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,6 @@ To have more control over the plugin you can passe a config file via `configPath ```yaml custom: serverless-aws-lambda: - port: 3000 configPath: ./config.default ``` @@ -164,6 +163,7 @@ See [defineConfig](resources/defineConfig.md) for advanced configuration. - [AWS Local S3](resources/s3.md) - [AWS Local SNS](resources/sns.md) - [AWS Local SQS](resources/sqs.md) +- [DocumentDB Local Streams](https://github.com/Inqnuam/serverless-aws-lambda-documentdb-streams) - [DynamoDB Local Streams](https://github.com/Inqnuam/serverless-aws-lambda-ddb-streams) - [Jest](https://github.com/Inqnuam/serverless-aws-lambda-jest) - [Vitest](https://github.com/Inqnuam/serverless-aws-lambda-vitest) diff --git a/build.mjs b/build.mjs index 6c20bed..792e51a 100644 --- a/build.mjs +++ b/build.mjs @@ -10,7 +10,7 @@ const compileDeclarations = () => { console.log(error.output?.[1]?.toString()); } }; -const external = ["esbuild", "archiver", "serve-static"]; +const external = ["esbuild", "archiver", "serve-static", "@aws-sdk/eventstream-codec"]; const watchPlugin = { name: "watch-plugin", setup: (build) => { @@ -40,7 +40,7 @@ const buildIndex = bundle.bind(null, { "./src/index.ts", "./src/server.ts", "./src/defineConfig.ts", - "./src/lib/runtime/worker.ts", + "./src/lib/runtime/runners/node.ts", "./src/lambda/router.ts", "./src/plugins/sns/index.ts", "./src/plugins/sqs/index.ts", @@ -51,7 +51,15 @@ const buildIndex = bundle.bind(null, { const buildRouterESM = bundle.bind(null, { ...esBuildConfig, - entryPoints: ["./src/lambda/router.ts", "./src/server.ts", "./src/lambda/body-parser.ts"], + entryPoints: [ + "./src/lambda/router.ts", + "./src/server.ts", + "./src/lambda/body-parser.ts", + "./src/defineConfig.ts", + "./src/plugins/sns/index.ts", + "./src/plugins/sqs/index.ts", + "./src/plugins/s3/index.ts", + ], format: "esm", outExtension: { ".js": ".mjs" }, }); diff --git a/package.json b/package.json index 371b67b..ea6c051 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverless-aws-lambda", - "version": "4.4.4", + "version": "4.5.0", "description": "AWS Application Load Balancer and API Gateway - Lambda dev tool for Serverless. Allows Express synthax in handlers. Supports packaging, local invoking and offline ALB, APG, S3, SNS, SQS, DynamoDB Stream server mocking.", "author": "Inqnuam", "license": "MIT", @@ -17,7 +17,8 @@ }, "./defineConfig": { "types": "./dist/defineConfig.d.ts", - "require": "./dist/defineConfig.js" + "require": "./dist/defineConfig.js", + "import": "./dist/defineConfig.mjs" }, "./router": { "types": "./dist/lambda/router.d.ts", @@ -36,28 +37,32 @@ }, "./sns": { "types": "./dist/plugins/sns/index.d.ts", - "require": "./dist/plugins/sns/index.js" + "require": "./dist/plugins/sns/index.js", + "import": "./dist/plugins/sns/index.mjs" }, "./sqs": { "types": "./dist/plugins/sqs/index.d.ts", - "require": "./dist/plugins/sqs/index.js" + "require": "./dist/plugins/sqs/index.js", + "import": "./dist/plugins/sqs/index.mjs" }, "./s3": { "types": "./dist/plugins/s3/index.d.ts", - "require": "./dist/plugins/s3/index.js" + "require": "./dist/plugins/s3/index.js", + "import": "./dist/plugins/s3/index.mjs" } }, "dependencies": { + "@aws-sdk/eventstream-codec": "^3.310.0", "@types/serverless": "^3.12.11", "archiver": "^5.3.1", - "esbuild": "0.17.15", + "esbuild": "0.17.18", "serve-static": "^1.15.0" }, "devDependencies": { - "@types/archiver": "^5.3.1", + "@types/archiver": "^5.3.2", "@types/node": "^14.14.31", "@types/serve-static": "^1.15.1", - "typescript": "^4.9.5" + "typescript": "^5.0.4" }, "keywords": [ "aws", @@ -73,6 +78,7 @@ "s3", "stream", "dynamodb", + "documentdb", "invoke", "bundle", "esbuild", diff --git a/resources/defineConfig.md b/resources/defineConfig.md index 0ebeb74..4f5881c 100644 --- a/resources/defineConfig.md +++ b/resources/defineConfig.md @@ -85,3 +85,18 @@ module.exports = defineConfig({ plugins: [myCustomPlugin], }); ``` + +`defineConfig` can be imported as ESM as well. + +```js +import { defineConfig } from "serverless-aws-lambda/defineConfig"; +import { sqsPlugin } from "serverless-aws-lambda/sqs"; + +export default defineConfig({ + offline: { + staticPath: "./.aws_lambda", + port: 9999, + }, + plugins: [sqsPlugin()], +}); +``` diff --git a/resources/esbuild.md b/resources/esbuild.md index 0da89fa..8fb40bf 100644 --- a/resources/esbuild.md +++ b/resources/esbuild.md @@ -32,3 +32,4 @@ Most of esbuild options are supported. It isn't the case for example for `entryP | mainFields | array | | | | nodePaths | array | | | | jsx | string | | | +| format | string | cjs | | diff --git a/resources/express.md b/resources/express.md index 2d5a92b..a318756 100644 --- a/resources/express.md +++ b/resources/express.md @@ -22,7 +22,7 @@ import { playersController } from "../controllers/playersController"; const route = Router(); -route.handle(auth, playersController); +route.use(auth, playersController); route.use((error, req, res, next) => { console.log(error); @@ -32,9 +32,7 @@ route.use((error, req, res, next) => { export default route; ``` -`route.handle` is similar to Express [app.METHOD("/somePath, ...")](https://expressjs.com/en/4x/api.html#app), a function (async or not) which accepts 3 arguments. request, response and next. - -It is also possible to use `route.use` instead of `route.handle`. +`route.use` is similar to Express [app.use(...)](https://expressjs.com/en/4x/api.html#app), a function (async or not) which accepts 3-4 arguments. request, response and next. ```js const route = Router(); @@ -63,28 +61,32 @@ route.use(auth).use(playersController).use(errorHandler); or with multi argument: ```js -const route = Router(); +import { Router } from "serverless-aws-lambda/router"; + +const handler = Router(); const errorHandler = (error, req, res, next) => { console.log(error); res.status(500).send("Internal Server Error"); }; -route.use(auth, playersController, errorHandler); +handler.use(auth, playersController, errorHandler); + +export { handler }; ``` ### Request -| property | type | doc | info | -| -------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| body | any | [doc](https://expressjs.com/en/4x/api.html#req.body) | Request with json content-type are automatically parsed. Use body-parser middleware from the package to parse Form Data and files | -| cookies | key-value | [doc](https://expressjs.com/en/4x/api.html#req.cookies) | compatible with Express's cookie-parser | -| method | string | [doc](https://expressjs.com/en/4x/api.html#req.method) | | -| params | string[] | As we don't handle custom routes we can't support named params, instead `params` will return an array of string containing `path` components separated by `/` (without `query` string) | Not compatible with Express | -| path | string | [doc](https://expressjs.com/en/4x/api.html#req.path) | | -| protocol | string | [doc](https://expressjs.com/en/4x/api.html#req.protocol) | | -| query | key-value | [doc](https://expressjs.com/en/4x/api.html#req.query) | | -| get | function | [doc](https://expressjs.com/en/4x/api.html#req.get) | | +| property | type | doc | info | +| -------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| body | any | [doc](https://expressjs.com/en/4x/api.html#req.body) | Request with json content-type are automatically parsed. Use body-parser middleware from `serverless-aws-lambda/body-parser` to parse Form Data and files | +| cookies | key-value | [doc](https://expressjs.com/en/4x/api.html#req.cookies) | compatible with Express's cookie-parser | +| method | string | [doc](https://expressjs.com/en/4x/api.html#req.method) | | +| params | string[] | As we don't handle custom routes we can't support named params, instead `params` will return an array of string containing `path` components separated by `/` (without `query` string) | Not compatible with Express | +| path | string | [doc](https://expressjs.com/en/4x/api.html#req.path) | | +| protocol | string | [doc](https://expressjs.com/en/4x/api.html#req.protocol) | | +| query | key-value | [doc](https://expressjs.com/en/4x/api.html#req.query) | | +| get | function | [doc](https://expressjs.com/en/4x/api.html#req.get) | | ++ includes also `event` raw object from AWS Lambda (except "`cookies`" which can be easly parsed with `cookie-parser` middleware) @@ -110,4 +112,9 @@ route.use(auth, playersController, errorHandler); ### Next -similar to ExpressJs next function +Similar to ExpressJs next function. + +`next()` can take one argument. +If an argument is provided Router triggers next middleware which has 4 arguments. +This is usally used to handle errors (see examples above). +Check Express documentation for more info. diff --git a/src/config.ts b/src/config.ts index e8d8456..5dc144f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import type { BuildOptions, BuildResult } from "esbuild"; import { IncomingMessage, ServerResponse } from "http"; import type { HttpMethod } from "./lib/server/handlers"; +import type { awslambda } from "./lib/runtime/awslambda"; export interface OfflineConfig { staticPath?: string; port?: number; @@ -13,7 +14,7 @@ export interface OfflineConfig { } export interface Config { - esbuild?: Omit; + esbuild?: Omit & { format?: "cjs" | "esm" }; offline?: OfflineConfig; buildCallback?: (result: BuildResult, isRebuild: boolean) => Promise | void; afterDeployCallbacks?: (() => Promise | void)[]; @@ -26,3 +27,7 @@ export interface ServerConfig { port?: number; onRebuild?: () => Promise | void; } + +declare global { + const awslambda: awslambda; +} diff --git a/src/defineConfig.ts b/src/defineConfig.ts index 5bbe243..7f403c4 100644 --- a/src/defineConfig.ts +++ b/src/defineConfig.ts @@ -1,6 +1,6 @@ import type { PluginBuild, BuildResult } from "esbuild"; import type { Config, OfflineConfig } from "./config"; -import type { ILambdaMock } from "./lib/runtime/lambdaMock"; +import type { ILambdaMock } from "./lib/runtime/rapidApi"; import type { HttpMethod } from "./lib/server/handlers"; import type { IncomingMessage, ServerResponse } from "http"; import type Serverless from "serverless"; @@ -19,13 +19,18 @@ export type ILambda = { * Be notified when this lambda is invoked. */ onInvoke: (callback: (event: any, info?: any) => void) => void; -} & Omit; + onInvokeError: (callback: (input: any, error: any, info?: any) => void) => void; + onInvokeSuccess: (callback: (input: any, output: any, info?: any) => void) => void; +} & Omit; export interface ClientConfigParams { stop: (err?: any) => Promise; lambdas: ILambda[]; isDeploying: boolean; isPackaging: boolean; + /** + * @deprecated use `someLambda.setEnv(key, value)` instead. + */ setEnv: (lambdaName: string, key: string, value: string) => void; stage: string; esbuild: PluginBuild["esbuild"]; @@ -40,6 +45,15 @@ export interface ClientConfigParams { }; } +export interface OfflineRequest { + /** + * @default "ANY" + */ + method?: HttpMethod | HttpMethod[]; + filter: string | RegExp; + callback: (this: ClientConfigParams, req: IncomingMessage, res: ServerResponse) => Promise | any | void; +} + export interface SlsAwsLambdaPlugin { name: string; buildCallback?: (this: ClientConfigParams, result: BuildResult, isRebuild: boolean) => Promise | void; @@ -49,22 +63,18 @@ export interface SlsAwsLambdaPlugin { offline?: { onReady?: (this: ClientConfigParams, port: number, ip: string) => Promise | void; /** - * Add new requests to the offline server. + * Add new requests to the local server. */ - request?: { - /** - * @default "ANY" - */ - method?: HttpMethod | HttpMethod[]; - filter: string | RegExp; - callback: (this: ClientConfigParams, req: IncomingMessage, res: ServerResponse) => Promise | any | void; - }[]; + request?: OfflineRequest[]; }; } export interface Options { esbuild?: Config["esbuild"]; offline?: { + /** + * Serve files locally from provided directory + */ staticPath?: string; port?: number; }; diff --git a/src/index.ts b/src/index.ts index 3e49279..d0fbf60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ import path from "path"; import { Daemon } from "./lib/server/daemon"; -import { ILambdaMock } from "./lib/runtime/lambdaMock"; +import { ILambdaMock } from "./lib/runtime/rapidApi"; import { log } from "./lib/utils/colorize"; import { zip } from "./lib/utils/zip"; import type { IZipOptions } from "./lib/utils/zip"; import esbuild from "esbuild"; -import type { BuildOptions, BuildResult } from "esbuild"; +import type { BuildOptions, BuildResult, Metafile } from "esbuild"; import type Serverless from "serverless"; import { Handlers } from "./lib/server/handlers"; import { buildOptimizer } from "./lib/esbuild/buildOptimizer"; @@ -13,6 +13,10 @@ import { parseEvents, parseDestination } from "./lib/parseEvents/index"; import { getResources } from "./lib/parseEvents/getResources"; import { parseCustomEsbuild } from "./lib/esbuild/parseCustomEsbuild"; import { mergeEsbuildConfig } from "./lib/esbuild/mergeEsbuildConfig"; +import { parseFuncUrl } from "./lib/parseEvents/funcUrl"; +import { LambdaRequests } from "./plugins/lambda/index"; +import { readDefineConfig } from "./lib/utils/readDefineConfig"; + const cwd = process.cwd(); const DEFAULT_LAMBDA_TIMEOUT = 6; const DEFAULT_LAMBDA_MEMORY_SIZE = 1024; @@ -21,6 +25,11 @@ const isLocalEnv = { IS_LOCAL: true, }; +interface PluginUtils { + log: Function; + writeText: Function; + progress: { get: Function; create: Function }; +} class ServerlessAwsLambda extends Daemon { #lambdas: ILambdaMock[]; watch = true; @@ -28,6 +37,7 @@ class ServerlessAwsLambda extends Daemon { isPackaging = false; serverless: Serverless; options: any; + stage: string; pluginConfig: any; commands: any; hooks: any; @@ -45,7 +55,7 @@ class ServerlessAwsLambda extends Daemon { sns: {}; sqs: {}; }; - constructor(serverless: any, options: any) { + constructor(serverless: any, options: any, pluginUtils: PluginUtils) { super({ debug: process.env.SLS_DEBUG == "*" }); this.#lambdas = []; @@ -55,7 +65,7 @@ class ServerlessAwsLambda extends Daemon { this.isPackaging = this.serverless.processedInput.commands.includes("package"); // @ts-ignore this.isDeploying = this.serverless.processedInput.commands.includes("deploy"); - + this.stage = this.options.stage ?? this.serverless.service.provider.stage ?? "dev"; if (!this.serverless.service.provider.runtime) { throw new Error("Please provide 'runtime' inside your serverless.yml > provider > runtime"); } else if (this.serverless.service.provider.runtime.startsWith("node")) { @@ -72,7 +82,7 @@ class ServerlessAwsLambda extends Daemon { serverless.configSchemaHandler.defineFunctionProperties("aws", { properties: { virtualEnvs: { type: "object" }, - online: { type: "boolean" }, + online: { anyOf: [{ type: "array", items: { type: "string" } }, { type: "boolean" }, { type: "string" }] }, files: { type: "array" }, }, }); @@ -80,18 +90,7 @@ class ServerlessAwsLambda extends Daemon { if (this.serverless.service.custom) { this.pluginConfig = this.serverless.service.custom["serverless-aws-lambda"]; this.defaultVirtualEnvs = this.serverless.service.custom["virtualEnvs"] ?? {}; - ServerlessAwsLambda.PORT = this.pluginConfig?.port; - } - - const cmdPort = this.options.p ?? this.options.port; - if (cmdPort) { - ServerlessAwsLambda.PORT = cmdPort; - } - - const processPort = Number(process.env.PORT); - - if (!isNaN(processPort)) { - ServerlessAwsLambda.PORT = processPort; + this.#setPort(); } this.commands = { @@ -151,7 +150,18 @@ class ServerlessAwsLambda extends Daemon { excludeFunctions() { // @ts-ignore Object.entries(this.serverless.service.functions).forEach(([name, { online }]) => { - if (typeof online == "boolean" && online === false) { + const valType = typeof online; + let mustSkip = false; + if (valType == "boolean" && online === false) { + mustSkip = true; + } else if (valType == "string" && online != this.stage) { + mustSkip = true; + } else if (Array.isArray(online) && !online.includes(this.stage)) { + mustSkip = true; + } + + if (mustSkip) { + console.log("Skipping", name); delete this.serverless.service.functions[name]; } }); @@ -176,7 +186,7 @@ class ServerlessAwsLambda extends Daemon { if (typeof this.nodeVersion == "string" && !isNaN(Number(this.nodeVersion))) { this.nodeVersion = Number(this.nodeVersion); - if (Number(process.versions.node.slice(0, 2)) < Number(this.nodeVersion)) { + if (Number(process.versions.node.slice(0, 2)) < this.nodeVersion) { log.RED(`You are running on NodeJS ${process.version} which is lower than '${this.nodeVersion}' found in serverless.yml.`); } esBuildConfig.target = `node${this.nodeVersion}`; @@ -200,7 +210,7 @@ class ServerlessAwsLambda extends Daemon { } } - buildCallback = async (result: BuildResult, isRebuild: boolean) => { + buildCallback = async (result: BuildResult, isRebuild: boolean, format: string) => { if (isRebuild) { await this.#onRebuild(result); } else { @@ -232,6 +242,7 @@ class ServerlessAwsLambda extends Daemon { } } // TODO: convert to promise all + // remove this filter ? for (const l of packageLambdas.filter((x) => x.online)) { const slsDeclaration = this.serverless.service.getFunction(l.name) as Serverless.FunctionDefinitionHandler; @@ -247,6 +258,7 @@ class ServerlessAwsLambda extends Daemon { zipName: l.outName, include: filesToInclude, sourcemap: this.esBuildConfig.sourcemap, + format, }; const zipOutputPath = await zip(zipOptions); @@ -259,7 +271,7 @@ class ServerlessAwsLambda extends Daemon { Handlers.PORT = port; await this.load(this.#lambdas); - let output = `✅ AWS Lambda offline server is listening on http://localhost:${port}`; + let output = `✅ AWS Lambda local server is listening on http://localhost:${port}`; if (localIp) { output += ` | http://${localIp}:${port}`; @@ -275,7 +287,7 @@ class ServerlessAwsLambda extends Daemon { async #onRebuild(result: BuildResult) { if (result?.errors?.length) { - log.RED("watch build failed:"); + log.RED("build failed:"); console.error(result.errors); } else { this.#setLambdaEsOutputPaths(result.metafile!.outputs); @@ -337,6 +349,7 @@ class ServerlessAwsLambda extends Daemon { ddb: [], s3: [], kinesis: [], + documentDb: [], virtualEnvs: { ...this.defaultVirtualEnvs, ...(slsDeclaration.virtualEnvs ?? {}) }, online: typeof slsDeclaration.online == "boolean" ? slsDeclaration.online : true, environment: { @@ -359,6 +372,8 @@ class ServerlessAwsLambda extends Daemon { } }, invokeSub: [], + invokeSuccessSub: [], + invokeErrorSub: [], setEnv: (key: string, value: string) => { this.setEnv(funcName, key, value); }, @@ -382,6 +397,12 @@ class ServerlessAwsLambda extends Daemon { lambdaDef.onInvoke = (callback: (event: any, info?: any) => void) => { lambdaDef.invokeSub.push(callback); }; + lambdaDef.onInvokeSuccess = (callback: (event: any, info?: any) => void) => { + lambdaDef.invokeSuccessSub.push(callback); + }; + lambdaDef.onInvokeError = (callback: (event: any, info?: any) => void) => { + lambdaDef.invokeErrorSub.push(callback); + }; if (process.env.NODE_ENV) { lambdaDef.environment.NODE_ENV = process.env.NODE_ENV; @@ -392,20 +413,21 @@ class ServerlessAwsLambda extends Daemon { } lambdaDef.environment.AWS_LAMBDA_FUNCTION_MEMORY_SIZE = lambdaDef.memorySize; - + lambdaDef.url = parseFuncUrl(lambda); if (lambda.events.length) { let httpApiPayload = defaultHttpApiPayload; if (typeof slsDeclaration.httpApi?.payload == "string") { const { payload } = slsDeclaration.httpApi; httpApiPayload = payload == "1.0" ? 1 : payload == "2.0" ? 2 : defaultHttpApiPayload; } - const { endpoints, sns, sqs, ddb, s3, kinesis } = parseEvents(lambda.events, Outputs, this.resources, httpApiPayload); + const { endpoints, sns, sqs, ddb, s3, kinesis, documentDb } = parseEvents(lambda.events, Outputs, this.resources, httpApiPayload); lambdaDef.endpoints = endpoints; lambdaDef.sns = sns; lambdaDef.sqs = sqs; lambdaDef.ddb = ddb; lambdaDef.s3 = s3; lambdaDef.kinesis = kinesis; + lambdaDef.documentDb = documentDb; } // console.log(lambdaDef); accum.push(lambdaDef); @@ -414,26 +436,32 @@ class ServerlessAwsLambda extends Daemon { return lambdas; } - #setLambdaEsOutputPaths(outputs: any) { + #setLambdaEsOutputPaths(outputs: Metafile["outputs"]) { const outputNames = Object.keys(outputs) .filter((x) => !x.endsWith(".map") && outputs[x].entryPoint) .map((x) => { const element = outputs[x]; - const lastPointIndex = element.entryPoint.lastIndexOf("."); - const entryPoint = path.join(cwd, element.entryPoint.slice(0, lastPointIndex)); - const esOutputPath = path.join(cwd, x); - - return { - esOutputPath, - entryPoint, - }; + if (element.entryPoint) { + const lastPointIndex = element.entryPoint.lastIndexOf("."); + const entryPoint = path.join(cwd, element.entryPoint.slice(0, lastPointIndex)); + const esOutputPath = path.join(cwd, x); + + const ext = path.extname(element.entryPoint); + + return { + esOutputPath, + entryPoint, + ext, + }; + } }); this.#lambdas.forEach((x) => { - const foundOutput = outputNames.find((w) => w.entryPoint == x.esEntryPoint); + const foundOutput = outputNames.find((w) => w?.entryPoint == x.esEntryPoint); if (foundOutput) { x.esOutputPath = foundOutput.esOutputPath; + x.entryPoint = `${foundOutput.entryPoint}${foundOutput.ext}`; } }); } @@ -467,28 +495,12 @@ class ServerlessAwsLambda extends Daemon { } } async #setCustomEsBuildConfig() { - if (!this.pluginConfig || typeof this.pluginConfig?.configPath !== "string") { - return; - } - - const parsed = path.posix.parse(this.pluginConfig.configPath); - - const customFilePath = path.posix.join(parsed.dir, parsed.name); - const configObjectName = parsed.ext.slice(1); - const configPath = path.resolve(customFilePath); - - const exportedFunc = require(configPath); - - if (!exportedFunc) { - return; - } - const customConfigArgs = { stop: async (cb: (err?: any) => void) => { - this.stop(cb); if (this.buildContext.stop) { await this.buildContext.stop(); } + this.stop(cb); }, lambdas: this.#lambdas, isDeploying: this.isDeploying, @@ -501,41 +513,54 @@ class ServerlessAwsLambda extends Daemon { serverless: this.serverless, resources: this.resources, }; - let exportedObject: any = {}; - - if (typeof exportedFunc[configObjectName] == "function") { - exportedObject = await exportedFunc[configObjectName](customConfigArgs); - } else if (typeof exportedFunc == "function") { - exportedObject = await exportedFunc(customConfigArgs); - } else { - throw new Error(`Can not find config at: ${configPath}`); - } - if (typeof exportedObject.buildCallback == "function") { - this.customBuildCallback = exportedObject.buildCallback; - } + const customOfflineRequests = LambdaRequests.map((x) => { + // @ts-ignore + x.callback = x.callback.bind(customConfigArgs); + return x; + }); + let exportedObject: any = {}; - if (Array.isArray(exportedObject.afterDeployCallbacks)) { - this.afterDeployCallbacks = exportedObject.afterDeployCallbacks; - } + const definedConfig = await readDefineConfig(this.pluginConfig.configPath); + if (definedConfig && definedConfig.exportedFunc) { + const { exportedFunc, configObjectName, configPath } = definedConfig; - if (exportedObject.offline && typeof exportedObject.offline == "object") { - if (Array.isArray(exportedObject.offline.request)) { - this.customOfflineRequests.push(...exportedObject.offline.request); + if (typeof exportedFunc[configObjectName] == "function") { + exportedObject = await exportedFunc[configObjectName](customConfigArgs); + } else if (typeof exportedFunc == "function") { + exportedObject = await exportedFunc(customConfigArgs); + } else if (typeof exportedFunc.default == "function") { + exportedObject = await exportedFunc.default(customConfigArgs); + } else { + throw new Error(`Can not find config at: ${configPath}`); } - if (typeof exportedObject.offline.staticPath == "string") { - this.serve = exportedObject.offline.staticPath; + if (typeof exportedObject.buildCallback == "function") { + this.customBuildCallback = exportedObject.buildCallback; } - if (typeof exportedObject.offline.port == "number") { - ServerlessAwsLambda.PORT = exportedObject.offline.port; + + if (Array.isArray(exportedObject.afterDeployCallbacks)) { + this.afterDeployCallbacks = exportedObject.afterDeployCallbacks; } - if (typeof exportedObject.offline.onReady == "function") { - this.onReady = exportedObject.offline.onReady; + if (exportedObject.offline && typeof exportedObject.offline == "object") { + if (Array.isArray(exportedObject.offline.request)) { + customOfflineRequests.unshift(...exportedObject.offline.request); + } + + if (typeof exportedObject.offline.staticPath == "string") { + this.serve = exportedObject.offline.staticPath; + } + if (typeof exportedObject.offline.port == "number" && !ServerlessAwsLambda.PORT) { + ServerlessAwsLambda.PORT = exportedObject.offline.port; + } + + if (typeof exportedObject.offline.onReady == "function") { + this.onReady = exportedObject.offline.onReady; + } } } - + this.customOfflineRequests = customOfflineRequests; const customConfig = exportedObject.esbuild; if (!customConfig) { return; @@ -546,6 +571,23 @@ class ServerlessAwsLambda extends Daemon { this.customEsBuildConfig = customEsBuild; } } + + #setPort = () => { + if (this.pluginConfig && !isNaN(this.pluginConfig.port)) { + ServerlessAwsLambda.PORT = this.pluginConfig.port; + } + + const cmdPort = this.options.p ?? this.options.port; + if (!isNaN(cmdPort)) { + ServerlessAwsLambda.PORT = cmdPort; + } + + const processPort = Number(process.env.PORT); + + if (!isNaN(processPort)) { + ServerlessAwsLambda.PORT = processPort; + } + }; } module.exports = ServerlessAwsLambda; diff --git a/src/lambda/express/request.ts b/src/lambda/express/request.ts index 3c93b13..c58054a 100644 --- a/src/lambda/express/request.ts +++ b/src/lambda/express/request.ts @@ -2,10 +2,10 @@ type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTION export interface RawAPIResponseContent { cookies?: string[]; - isBase64Encoded: boolean; + isBase64Encoded?: boolean; statusCode: number; - headers: { [key: string]: any }; - body: string | null | undefined; + headers?: { [key: string]: any }; + body?: string | null | undefined; } export interface RawResponseContent { @@ -13,23 +13,59 @@ export interface RawResponseContent { [key: string]: any; } -export interface IRequest { +interface KnownRequestProperties { requestContext: { [key: string]: any }; httpMethod: HttpMethod; queryStringParameters: { [key: string]: string }; path: string; headers: any; isBase64Encoded: boolean; + /** + * parsed queryStringParameters. + * + * Values may be `array` if multiValueQueryStringParameters is available and there's multiples values for the key. + * + */ query: any; + /** + * Request body. + * + * Tries to parse with JSON.parse(). + * + * Use `body-parser` middleware from `serverless-aws-lambda/body-parser` to parse Form-Data. + */ body: any; method: HttpMethod; - get: (headerField: string) => { [key: string]: any } | undefined; + /** + * Get header value by key. + * + * Case insensitive. + */ + get: (headerKey: string) => string | string[]; + /** + * request url splitted with `/`. + */ params: string[]; protocol: string; secure: boolean; + /** + * Use `body-parser` middleware from `serverless-aws-lambda/body-parser` to get files as Buffers. + * + * Otherwise it will be undefined. + */ files?: any[]; + /** + * Use {@link https://www.npmjs.com/package/cookie-parser cookie-parser} middleware to get parsed cookies. + * + * Otherwise it will be undefined. + */ + cookies?: { + [key: string]: any; + }; } +export type IRequest = KnownRequestProperties & { [key: string]: any }; + export const _buildUniversalEvent = (event: any) => { let uE = { ...event }; try { @@ -48,6 +84,7 @@ export const _buildUniversalEvent = (event: any) => { } } } else if (event.queryStringParameters) { + // TODO maybe getAll from search params ? for (const [key, value] of Object.entries(event.queryStringParameters)) { uE.query[key] = decodeURIComponent(value as string); } diff --git a/src/lambda/express/response.ts b/src/lambda/express/response.ts index b6db5bc..adf3076 100644 --- a/src/lambda/express/response.ts +++ b/src/lambda/express/response.ts @@ -2,7 +2,7 @@ import { RawResponseContent } from "./request"; import { cookie, CookieOptions } from "./cookies"; export type RedirectOptions = [code: number, path: string]; - +type Stringifiable = [] | { [key: string]: any } | null | boolean; export interface IResponse { locals: { [key: string]: any }; callbackWaitsForEmptyEventLoop: boolean; @@ -22,9 +22,9 @@ export interface IResponse { getRemainingTimeInMillis: Function; status: (code: number) => this; sendStatus: (code: number) => void; - send: (content?: string) => void; + send: (content?: string | Buffer) => void; end: (rawContent: any) => void; - json: (content: [] | { [key: string]: any }) => void; + json: (content: Stringifiable) => void; set: (header: string | { [key: string]: string }, value?: string) => this; setHeader: (header: string | { [key: string]: string }, value?: string) => this; get: (headerKey: string) => string; @@ -36,6 +36,17 @@ export interface IResponse { clearCookie(name: string, options?: CookieOptions): this; } +const getSetCookieKey = (i: number) => { + if (i == 0 || i > 8) { + return "Set-Cookie"; + } else { + const sc = ["s", "e", "t", "c", "o", "o", "k", "i", "e"]; + sc[i] = sc[i].toUpperCase(); + + return [...sc.slice(0, 3), "-", ...sc.slice(3)].join(""); + } +}; + export class _Response implements IResponse { locals: { [key: string]: any }; callbackWaitsForEmptyEventLoop: boolean; @@ -119,7 +130,10 @@ export class _Response implements IResponse { delete this.responseObject.cookies; } if (this.#req.requestContext?.elb && !this.#req.multiValueHeaders && !this.#req.multiValueQueryStringParameters) { - this.#setHeader("Set-Cookie", this.responseObject.cookies[this.responseObject.cookies.length - 1]); + (this.responseObject.cookies as []).forEach((cookie, i) => { + this.#setHeader(getSetCookieKey(i), cookie); + }); + delete this.responseObject.cookies; } } @@ -138,8 +152,13 @@ export class _Response implements IResponse { } this.#resolve({ ...this.responseObject }); } - #setBody(content?: string): this { - this.responseObject.body = content; + #setBody(content?: string | Buffer): this { + if (content instanceof Buffer) { + this.responseObject.body = content.toString("base64"); + this.responseObject.isBase64Encoded = true; + } else { + this.responseObject.body = content; + } return this; } cookie(name: string, value: string, options?: CookieOptions): this { @@ -187,11 +206,11 @@ export class _Response implements IResponse { return this.responseObject.headers[headerKey]; } getHeader = this.get; - json(content: { [key: string]: any }) { + json(content: Stringifiable) { this.type("application/json").#setBody(JSON.stringify(content)).#sendResponse(); } - send(content?: string) { + send(content?: string | Buffer) { this.#setBody(content).#sendResponse(); } end(rawContent: any) { @@ -223,7 +242,7 @@ export class _Response implements IResponse { } links(links: any): this { - var link = this.get("Link") || ""; + let link = this.get("Link") || ""; if (link) link += ", "; return this.set( "Link", diff --git a/src/lambda/router.ts b/src/lambda/router.ts index 7683f63..aa3f23b 100644 --- a/src/lambda/router.ts +++ b/src/lambda/router.ts @@ -138,11 +138,18 @@ class Route extends Function { return response ?? { statusCode: 204 }; } + /** + * Express like route.ANY() without path filter. + * @deprecated use `.use()` instead. + */ handle(...controllers: (RouteController | Function)[]) { this.controllers.push(...controllers); return this; } + /** + * Express like route.use() + */ use(...middlewares: (RouteMiddleware | RouteController | Function)[]) { this.controllers.push(...middlewares); return this; diff --git a/src/lib/esbuild/buildOptimizer.ts b/src/lib/esbuild/buildOptimizer.ts index 547e748..7f66fbe 100644 --- a/src/lib/esbuild/buildOptimizer.ts +++ b/src/lib/esbuild/buildOptimizer.ts @@ -2,6 +2,8 @@ import type { Plugin, OnResolveArgs, BuildResult } from "esbuild"; import { knownCjs } from "./knownCjs"; const awsSdkV3 = { filter: /^@aws-sdk\//, namespace: "file" }; +const awslambda = `${__dirname.slice(0, -5)}/src/lib/runtime/awslambda.ts`; + const isExternal = (args: OnResolveArgs) => { return { path: args.path, @@ -16,7 +18,7 @@ export const buildOptimizer = ({ }: { isLocal: boolean; nodeVersion: number; - buildCallback: (result: BuildResult, isRebuild: boolean) => void | Promise; + buildCallback: (result: BuildResult, isRebuild: boolean, format: string) => void | Promise; }): Plugin => { return { name: "build-optimizer-plugin", @@ -24,8 +26,19 @@ export const buildOptimizer = ({ let isRebuild = false; build.onEnd(async (result) => { + if (!isRebuild && result?.errors?.length) { + process.exit(1); + } + if (isRebuild) { + // @ts-ignore + globalThis.sco.forEach((socket) => { + if (socket.writable) { + socket.destroy(); + } + }); + } try { - await buildCallback(result, isRebuild); + await buildCallback(result, isRebuild, build.initialOptions.format!); } catch (error) { console.log(error); } @@ -33,6 +46,12 @@ export const buildOptimizer = ({ }); if (isLocal) { + if (Array.isArray(build.initialOptions.inject)) { + build.initialOptions.inject.push(awslambda); + } else { + build.initialOptions.inject = [awslambda]; + } + build.initialOptions.external!.push(...knownCjs); build.onResolve(awsSdkV3, isExternal); diff --git a/src/lib/esbuild/mergeEsbuildConfig.ts b/src/lib/esbuild/mergeEsbuildConfig.ts index 434268e..7b8d172 100644 --- a/src/lib/esbuild/mergeEsbuildConfig.ts +++ b/src/lib/esbuild/mergeEsbuildConfig.ts @@ -16,6 +16,9 @@ export const mergeEsbuildConfig = (esBuildConfig: BuildOptions, customEsBuildCon if (typeof customEsBuildConfig.sourceRoot == "string") { esBuildConfig.sourceRoot = customEsBuildConfig.sourceRoot; } + if (typeof customEsBuildConfig.format == "string") { + esBuildConfig.format = customEsBuildConfig.format; + } if ("sourcesContent" in customEsBuildConfig) { esBuildConfig.sourcesContent = customEsBuildConfig.sourcesContent; diff --git a/src/lib/esbuild/parseCustomEsbuild.ts b/src/lib/esbuild/parseCustomEsbuild.ts index ad09ac3..96b9be1 100644 --- a/src/lib/esbuild/parseCustomEsbuild.ts +++ b/src/lib/esbuild/parseCustomEsbuild.ts @@ -18,6 +18,10 @@ export const parseCustomEsbuild = (customConfig: BuildOptions) => { customEsBuild.sourceRoot = customConfig.sourceRoot; } + if (typeof customConfig.format == "string") { + customEsBuild.format = customConfig.format; + } + if ("sourcesContent" in customConfig) { customEsBuild.sourcesContent = customConfig.sourcesContent; } diff --git a/src/lib/parseEvents/documentDb.ts b/src/lib/parseEvents/documentDb.ts new file mode 100644 index 0000000..f2d8077 --- /dev/null +++ b/src/lib/parseEvents/documentDb.ts @@ -0,0 +1,22 @@ +export interface IDocumentDbEvent { + cluster: string; + smk: string; + db: string; + auth?: "BASIC_AUTH"; + batchSize?: number; + batchWindow?: number; + collection?: string; + document?: "Default" | "UpdateLookup"; + enabled?: boolean; + startingPosition?: "LATEST" | "TRIM_HORIZON" | "AT_TIMESTAMP"; +} + +export const parseDocumentDb = (Outputs: any, resources: any, event: any): IDocumentDbEvent | undefined => { + if (!event.documentDb) { + return; + } + + let parsedEvent: any = { ...event.documentDb }; + + return parsedEvent; +}; diff --git a/src/lib/parseEvents/endpoints.ts b/src/lib/parseEvents/endpoints.ts index 12f2d3d..cb4bc0f 100644 --- a/src/lib/parseEvents/endpoints.ts +++ b/src/lib/parseEvents/endpoints.ts @@ -1,8 +1,18 @@ import { HttpMethod } from "../server/handlers"; +import { log } from "../utils/colorize"; +const pathPartsRegex = /^(\{[\w.:-]+\+?\}|[a-zA-Z0-9.:_-]+)$/; + +interface IAlbQuery { + Key?: string; + Value: string; +} +type query = IAlbQuery[]; export interface LambdaEndpoint { - kind: "alb" | "apg"; + kind: "alb" | "apg" | "url"; + proxy?: "url" | "http" | "httpApi"; paths: string[]; + pathsRegex: RegExp[]; methods: HttpMethod[]; async?: boolean; multiValueHeaders?: boolean; @@ -10,10 +20,12 @@ export interface LambdaEndpoint { header?: { name: string; values: string[]; - }; - query?: { - [key: string]: string; - }; + }[]; + query?: query[]; + headers?: string[]; + querystrings?: string[]; + requestPaths?: string[]; + stream?: boolean; } const supportedEvents = ["http", "httpApi", "alb"]; export const parseEndpoints = (event: any, httpApiPayload: LambdaEndpoint["version"]): LambdaEndpoint | null => { @@ -26,6 +38,7 @@ export const parseEndpoints = (event: any, httpApiPayload: LambdaEndpoint["versi let parsendEvent: LambdaEndpoint = { kind: "alb", paths: [], + pathsRegex: [], methods: ["ANY"], }; @@ -42,46 +55,117 @@ export const parseEndpoints = (event: any, httpApiPayload: LambdaEndpoint["versi if (event.alb.multiValueHeaders) { parsendEvent.multiValueHeaders = true; } + if (event.alb.conditions.header) { - parsendEvent.header = event.alb.conditions.header; + parsendEvent.header = Array.isArray(event.alb.conditions.header) ? event.alb.conditions.header : [event.alb.conditions.header]; } if (event.alb.conditions.query) { - parsendEvent.query = event.alb.conditions.query; + if (Array.isArray(event.alb.conditions.query)) { + parsendEvent.query = event.alb.conditions.query; + } else if (typeof event.alb.conditions.query == "object") { + const entries = Object.entries(event.alb.conditions.query) as unknown as [string, string]; + + const query = entries.map(([Key, Value]) => { + return { Key, Value }; + }); + parsendEvent.query = [query]; + } } } else if (event.http || event.httpApi) { + parsendEvent.kind = "apg"; + if (event.http) { parsendEvent.version = 1; - if (event.http.async) { - parsendEvent.async = true; - } + parsendEvent.proxy = "http"; } else { parsendEvent.version = httpApiPayload; + parsendEvent.proxy = "httpApi"; } - - parsendEvent.kind = "apg"; const httpEvent = event.http ?? event.httpApi; if (typeof httpEvent == "string") { - // ex: 'PUT /users/update' - const declarationComponents = httpEvent.split(" "); + if (httpEvent == "*") { + parsendEvent.methods = ["ANY"]; + parsendEvent.paths = ["*"]; + } else { + // ex: 'PUT /users/update' + const declarationComponents = httpEvent.split(" "); - if (declarationComponents.length != 2) { - return null; - } + if (declarationComponents.length != 2) { + return null; + } + const [method, path] = declarationComponents; - parsendEvent.methods = [declarationComponents[0] == "*" ? "ANY" : (declarationComponents[0].toUpperCase() as HttpMethod)]; - parsendEvent.paths = [declarationComponents[1]]; + parsendEvent.methods = [method == "*" ? "ANY" : (method.toUpperCase() as HttpMethod)]; + parsendEvent.paths = [path]; + } } else if (typeof httpEvent == "object" && httpEvent.path) { parsendEvent.paths = [httpEvent.path]; if (httpEvent.method) { parsendEvent.methods = [httpEvent.method == "*" ? "ANY" : httpEvent.method.toUpperCase()]; } + + if (event.http) { + if (event.http.async) { + parsendEvent.async = event.http.async; + } + if (event.http.request?.parameters) { + const { headers, querystrings, paths } = event.http.request.parameters; + if (headers) { + parsendEvent.headers = Object.keys(headers).filter((x) => headers[x]); + } + if (querystrings) { + parsendEvent.querystrings = Object.keys(querystrings).filter((x) => querystrings[x]); + } + + if (paths) { + parsendEvent.requestPaths = Object.keys(paths).filter((x) => paths[x]); + } + } + } } else { return null; } + + const pathParts = parsendEvent.paths[0].split("/").filter(Boolean); + const hasIndalidPath = pathParts.find((x) => !x.match(pathPartsRegex)); + if (hasIndalidPath && httpEvent != "*") { + log.YELLOW(`Invalid path parts: ${hasIndalidPath}`); + return null; + } } parsendEvent.paths = parsendEvent.paths.map((x) => (x.startsWith("/") ? x : `/${x}`)); + + if (event.alb) { + parsendEvent.paths.forEach((p) => { + const AlbAnyPathMatch = p.replace(/\*/g, ".*").replace(/\//g, "\\/"); + const AlbPattern = new RegExp(`^${AlbAnyPathMatch}$`, "g"); + parsendEvent.pathsRegex.push(AlbPattern); + }); + } else { + const reqPath = parsendEvent.paths[0]; + if (event.http && reqPath && reqPath.endsWith("/") && reqPath.length > 1) { + parsendEvent.paths[0] = reqPath.slice(0, -1); + } + + let ApgPathPartMatch = parsendEvent.paths[0]; + ApgPathPartMatch.replace("*", ".*") + .replace(/\{[\w.:-]+\+?\}/g, ".*") + .replace(/\//g, "\\/"); + + if (event.http && !ApgPathPartMatch.endsWith("/")) { + ApgPathPartMatch += "\\/?"; + } + + parsendEvent.pathsRegex = [new RegExp(`^${ApgPathPartMatch}$`, "g")]; + const endsWithSlash = parsendEvent.paths.find((x) => x.endsWith("/")); + if (event.httpApi && endsWithSlash && endsWithSlash != "/") { + log.YELLOW(`Invalid path, httpApi route must not end with '/': ${endsWithSlash}`); + return null; + } + } + return parsendEvent; }; diff --git a/src/lib/parseEvents/funcUrl.ts b/src/lib/parseEvents/funcUrl.ts new file mode 100644 index 0000000..3a5acaa --- /dev/null +++ b/src/lib/parseEvents/funcUrl.ts @@ -0,0 +1,17 @@ +import type { LambdaEndpoint } from "../runtime/rapidApi"; + +export const parseFuncUrl = (lambda: any) => { + if (!lambda.url) { + return; + } + let url: LambdaEndpoint = { + kind: "url", + proxy: "url", + version: 2, + methods: ["ANY"], + paths: ["/*"], + pathsRegex: [], + stream: typeof lambda.url == "object" && lambda.url.invoke == "response_stream", + }; + return url; +}; diff --git a/src/lib/parseEvents/index.ts b/src/lib/parseEvents/index.ts index 2f19622..028eee6 100644 --- a/src/lib/parseEvents/index.ts +++ b/src/lib/parseEvents/index.ts @@ -5,6 +5,7 @@ import { parseDdbStreamDefinitions } from "./ddbStream"; import { parseS3 } from "./s3"; import { parseKinesis } from "./kinesis"; import { parseSqs } from "./sqs"; +import { parseDocumentDb } from "./documentDb"; const supportedServices: IDestination["kind"][] = ["lambda", "sns", "sqs"]; type arn = [string, string, IDestination["kind"], string, string, string, string]; @@ -19,6 +20,7 @@ export const parseEvents = (events: any[], Outputs: any, resources: any, httpApi const ddb: any[] = []; const s3: any[] = []; const kinesis: any[] = []; + const documentDb: any[] = []; for (const event of events) { const slsEvent = parseEndpoints(event, httpApiPayload); const snsEvent = parseSns(Outputs, resources, event); @@ -26,6 +28,8 @@ export const parseEvents = (events: any[], Outputs: any, resources: any, httpApi const ddbStream = parseDdbStreamDefinitions(Outputs, resources, event); const s3Event = parseS3(event); const kinesisStream = parseKinesis(event, Outputs, resources); + const docDbStream = parseDocumentDb(Outputs, resources, event); + if (slsEvent) { endpoints.push(slsEvent); } @@ -47,9 +51,13 @@ export const parseEvents = (events: any[], Outputs: any, resources: any, httpApi if (kinesisStream) { kinesis.push(kinesisStream); } + + if (docDbStream) { + documentDb.push(docDbStream); + } } - return { ddb, endpoints, s3, sns, sqs, kinesis }; + return { ddb, endpoints, s3, sns, sqs, kinesis, documentDb }; }; export const parseDestination = (destination: any, Outputs: any, resources: any): IDestination | undefined => { diff --git a/src/lib/runtime/awslambda.ts b/src/lib/runtime/awslambda.ts new file mode 100644 index 0000000..059435b --- /dev/null +++ b/src/lib/runtime/awslambda.ts @@ -0,0 +1,21 @@ +import { HttpResponseStream, StreamableHandler } from "./streamResponse"; +import type { IHttpResponseStream } from "./streamResponse"; +type HandlerMetadata = { + highWaterMark?: number; +}; + +export interface awslambda { + streamifyResponse: (handler: StreamableHandler, options?: HandlerMetadata) => Function; + HttpResponseStream: IHttpResponseStream; +} + +export const awslambda: awslambda = { + streamifyResponse: function streamifyResponse(handler, options) { + // @ts-ignore + handler.stream = true; + // @ts-ignore + handler.streamOpt = options; + return handler; + }, + HttpResponseStream: HttpResponseStream, +}; diff --git a/src/lib/runtime/bufferedStreamResponse.ts b/src/lib/runtime/bufferedStreamResponse.ts new file mode 100644 index 0000000..8197312 --- /dev/null +++ b/src/lib/runtime/bufferedStreamResponse.ts @@ -0,0 +1,134 @@ +import type { LambdaEndpoint } from "../parseEvents/endpoints"; +import { log } from "../utils/colorize"; + +const httpIntegrationCt = "application/vnd.awslambda.http-integration-response"; + +export class BufferedStreamResponse { + buffer?: Uint8Array; + _isHttpIntegrationResponse: boolean; + _metaDelimiter: number; + _endDelimiter: number; + _ct?: any; + _isStreamResponse = true; + #mockEvent?: LambdaEndpoint; + static httpErrMsg = '{"message":"Internal Server Error"}'; + static amzMsgNull = '{"message":null}'; + static codec = new TextDecoder(); + static splitMessage = (buffer?: Uint8Array, metaDelimiter?: number) => { + if (!buffer) { + return; + } + + const bytes = Array.from(buffer.values()); + let foundIndex: number | null = null; + + if (metaDelimiter) { + foundIndex = metaDelimiter; + } else { + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]; + if (byte === 0) { + const nextSevenBytes = [bytes[i + 1], bytes[i + 2], bytes[i + 3], bytes[i + 4], bytes[i + 5], bytes[i + 6], bytes[i + 7]]; + if (nextSevenBytes.every((x) => x === 0)) { + foundIndex = i; + break; + } + } + } + } + + if (foundIndex) { + return new Uint8Array(bytes.slice(0, foundIndex)); + } + }; + constructor(mockEvent?: LambdaEndpoint) { + this._isHttpIntegrationResponse = false; + this._metaDelimiter = 0; + this._endDelimiter = 0; + this.#mockEvent = mockEvent; + } + setHeader(name: string, value: string) { + this._isHttpIntegrationResponse = !this.buffer && value == httpIntegrationCt && (!this._ct || this._ct == httpIntegrationCt); + this._ct = value; + } + write(chunk: Uint8Array, encoding?: BufferEncoding) { + if (!this._isHttpIntegrationResponse && !this._metaDelimiter) { + this._metaDelimiter = chunk.byteLength; + } + this.#collectChunk(chunk); + } + end(chunk?: Uint8Array) { + if (this.buffer) { + this._endDelimiter = this.buffer.byteLength; + } + if (chunk) { + this.#collectChunk(chunk); + } + } + destroy() {} + + #collectChunk(chunk: Uint8Array) { + this.buffer = typeof this.buffer == "undefined" ? chunk : Buffer.concat([this.buffer, chunk]); + } + getParsedResponse() { + if (!this.#mockEvent) { + return; + } + const kind = this.#mockEvent.kind; + if (kind == "alb") { + const response = this.#getAlbResponse(); + return response; + } else if (kind == "url") { + const response = this.#getFunctionUrlResponse(); + return response; + } else if (kind == "apg") { + const response = this.#getApgResponse(); + return response; + } + } + #getAlbResponse() { + const responseBody = this._isHttpIntegrationResponse ? BufferedStreamResponse.splitMessage(this.buffer) : this.buffer; + let responseData; + if (responseBody) { + responseData = this.#parseResponseData(responseBody); + } + return responseData; + } + #getApgResponse() { + if (this._isHttpIntegrationResponse) { + log.RED("awslambda.HttpResponseStream.from() is not supported with API Gateway"); + + return { + statusCode: 500, + headers: { + "Content-Type": "application/json", + }, + body: BufferedStreamResponse.httpErrMsg, + }; + } else if (this.buffer) { + const responseData = this.#parseResponseData(this.buffer); + + return responseData; + } + } + #getFunctionUrlResponse() { + const responseBody = BufferedStreamResponse.splitMessage(this.buffer, this._isHttpIntegrationResponse ? undefined : this._metaDelimiter); + let responseData; + if (responseBody) { + responseData = this.#parseResponseData(responseBody); + } + + return responseData; + } + + #parseResponseData(responseData: any) { + let data = BufferedStreamResponse.codec.decode(responseData); + + if (typeof data == "string") { + try { + data = JSON.parse(data); + } catch (error) {} + } + return data; + } +} diff --git a/src/lib/runtime/lambdaMock.ts b/src/lib/runtime/rapidApi.ts similarity index 58% rename from src/lib/runtime/lambdaMock.ts rename to src/lib/runtime/rapidApi.ts index 276a909..d1b52e4 100644 --- a/src/lib/runtime/lambdaMock.ts +++ b/src/lib/runtime/rapidApi.ts @@ -4,6 +4,8 @@ import { randomUUID } from "crypto"; import { EventEmitter } from "events"; import { log } from "../utils/colorize"; import { callErrorDest, callSuccessDest } from "./callDestinations"; +import { BufferedStreamResponse } from "./bufferedStreamResponse"; +import type { ServerResponse } from "http"; import type { ISnsEvent } from "../parseEvents/sns"; import type { IS3Event } from "../parseEvents/s3"; import type { ISqs } from "../parseEvents/sqs"; @@ -11,7 +13,11 @@ import type { IDdbEvent } from "../parseEvents/ddbStream"; import type { IDestination } from "../parseEvents/index"; import type { LambdaEndpoint } from "../parseEvents/endpoints"; import type { IKinesisEvent } from "../parseEvents/kinesis"; +import type { IDocumentDbEvent } from "../parseEvents/documentDb"; +type InvokeSub = (event: any, info?: any) => void; +type InvokeSuccessSub = (input: any, output: any, info?: any) => void; +type InvokeErrorSub = InvokeSuccessSub; export interface ILambdaMock { /** * Function name declared in serverless.yml. @@ -24,7 +30,7 @@ export interface ILambdaMock { /** * Deploy Lambda or not to AWS. */ - online: boolean; + online: boolean | string | string[]; /** * API Gateway and Application Load balancer events. */ @@ -34,6 +40,8 @@ export interface ILambdaMock { ddb: IDdbEvent[]; sqs: ISqs[]; kinesis: IKinesisEvent[]; + documentDb: IDocumentDbEvent[]; + url?: LambdaEndpoint; timeout: number; memorySize: number; environment: { [key: string]: any }; @@ -49,13 +57,22 @@ export interface ILambdaMock { * esbuild entry point absolute path. */ esEntryPoint: string; - + /** + * bundle absolute path. + */ esOutputPath: string; + /** + * resolved entry point. + */ entryPoint: string; invokeSub: InvokeSub[]; + invokeSuccessSub: InvokeSuccessSub[]; + invokeErrorSub: InvokeErrorSub[]; /** - * Invoke this lambda + * Invoke this lambda. + * * must always be called with "await" or ".then()" + * @returns {Promise} * @throws Error */ invoke: (event: any, info?: any, clientContext?: any) => Promise; @@ -64,10 +81,7 @@ export interface ILambdaMock { onFailure?: IDestination; } -type InvokeSub = (event: any, info?: any) => void; - const runtimeLifetime = 18 * 60 * 1000; -const workerPath = path.resolve(__dirname, "./lib/runtime/worker.js"); // https://aws.amazon.com/blogs/architecture/understanding-the-different-ways-to-invoke-lambda-functions/ const asyncEvents = new Set(["async", "ddb", "kinesis", "s3", "sns", "sqs"]); @@ -81,13 +95,15 @@ const isAsync = (info: any) => { export class LambdaMock extends EventEmitter implements ILambdaMock { name: string; outName: string; - online: boolean; + online: boolean | string | string[]; endpoints: LambdaEndpoint[]; s3: IS3Event[]; sns: ISnsEvent[]; sqs: ISqs[]; ddb: IDdbEvent[]; kinesis: IKinesisEvent[]; + documentDb: IDocumentDbEvent[]; + url?: LambdaEndpoint; timeout: number; memorySize: number; environment: { [key: string]: any }; @@ -100,10 +116,13 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { onSuccess?: IDestination; onFailure?: IDestination; invokeSub: InvokeSub[]; + invokeSuccessSub: InvokeSuccessSub[]; + invokeErrorSub: InvokeErrorSub[]; _worker?: Worker; _isLoaded: boolean = false; _isLoading: boolean = false; #_tmLifetime?: NodeJS.Timeout; + #workerPath: string; constructor({ name, outName, @@ -122,7 +141,11 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { sqs, ddb, kinesis, + url, + documentDb, invokeSub, + invokeSuccessSub, + invokeErrorSub, onError, onSuccess, onFailure, @@ -137,6 +160,8 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { this.sqs = sqs; this.ddb = ddb; this.kinesis = kinesis; + this.url = url; + this.documentDb = documentDb; this.timeout = timeout; this.memorySize = memorySize; this.environment = environment; @@ -146,24 +171,26 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { this.esOutputPath = esOutputPath; this.entryPoint = entryPoint; this.invokeSub = invokeSub; + this.invokeSuccessSub = invokeSuccessSub; + this.invokeErrorSub = invokeErrorSub; this.onError = onError; this.onSuccess = onSuccess; this.onFailure = onFailure; + this.#workerPath = path.resolve(__dirname, "./lib/runtime/runners/node.js"); } async importEventHandler() { await new Promise((resolve, reject) => { - this._worker = new Worker(workerPath, { + this._worker = new Worker(this.#workerPath, { env: this.environment, - resourceLimits: { - stackSizeMb: this.memorySize, - }, + execArgv: ["--enable-source-maps"], workerData: { name: this.name, timeout: this.timeout, memorySize: this.memorySize, esOutputPath: this.esOutputPath, handlerName: this.handlerName, + debug: log.getDebug(), }, } as WorkerOptions); @@ -179,7 +206,7 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { }; this._worker.on("message", (e) => { - const { channel, data, awsRequestId } = e; + const { channel, data, awsRequestId, type, encoding } = e; if (channel == "import") { this._worker!.setMaxListeners(55); this.setMaxListeners(10); @@ -189,7 +216,7 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { this.emit("loaded", true); resolve(undefined); } else { - this.emit(awsRequestId, channel, data); + this.emit(awsRequestId, channel, data, type, encoding); } }); this._worker.on("error", errorHandler); @@ -201,42 +228,50 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { console.error(error); } - if (!isAsync(info)) { - return; - } + this.invokeErrorSub.forEach((x) => { + try { + x(event, error, info); + } catch (error) {} + }); - const errParams = { - LOCAL_PORT: this.environment.LOCAL_PORT, - event, - payload: error, - requestId: awsRequestId, - lambdaName: this.outName, - }; - if (this.onError) { - callErrorDest({ ...errParams, destination: this.onError }); - } + if (isAsync(info)) { + const errParams = { + LOCAL_PORT: this.environment.LOCAL_PORT, + event, + payload: error, + requestId: awsRequestId, + lambdaName: this.outName, + }; + if (this.onError) { + callErrorDest({ ...errParams, destination: this.onError }); + } - if (this.onFailure) { - callErrorDest({ ...errParams, destination: this.onFailure }); + if (this.onFailure) { + callErrorDest({ ...errParams, destination: this.onFailure }); + } } } handleSuccessDestination(event: any, info: any, response: any, awsRequestId: string) { - if (!this.onSuccess || !isAsync(info)) { - return; + if (this.onSuccess && isAsync(info)) { + callSuccessDest({ + destination: this.onSuccess, + LOCAL_PORT: this.environment.LOCAL_PORT, + event, + payload: response, + requestId: awsRequestId, + lambdaName: this.outName, + }); } - callSuccessDest({ - destination: this.onSuccess, - LOCAL_PORT: this.environment.LOCAL_PORT, - event, - payload: response, - requestId: awsRequestId, - lambdaName: this.outName, + this.invokeSuccessSub.forEach((x) => { + try { + x(event, response, info); + } catch (error) {} }); } - async invoke(event: any, info?: any, clientContext?: any) { + async #load() { if (this._isLoading) { this.setMaxListeners(0); await new Promise((resolve, reject) => { @@ -253,7 +288,11 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { log.BR_BLUE(`❄️ Cold start '${this.outName}'`); await this.importEventHandler(); } - + } + async invoke(event: any, info?: any, clientContext?: any, response?: ServerResponse) { + await this.#load(); + const awsRequestId = randomUUID(); + const hrTimeStart = this.#printStart(awsRequestId, event, info); this.invokeSub.forEach((x) => { try { x(event, info); @@ -262,37 +301,59 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { try { const eventResponse = await new Promise((resolve, reject) => { - const awsRequestId = randomUUID(); - this._worker!.postMessage({ channel: "exec", data: { event, clientContext }, awsRequestId, }); + const res = response ?? new BufferedStreamResponse(info); - this.once(awsRequestId, (channel: string, data: any) => { + function listener(this: LambdaMock, channel: string, data: any, type: string, encoding: BufferEncoding) { switch (channel) { case "return": case "succeed": case "done": this.handleSuccessDestination(event, info, data, awsRequestId); + this.removeListener(awsRequestId, listener); resolve(data); break; case "fail": this.handleErrorDestination(event, info, data, awsRequestId); + this.removeListener(awsRequestId, listener); reject(data); break; + case "stream": + if (type == "write") { + res.write(data, encoding); + } else if (type == "ct") { + res.setHeader("Content-Type", data); + } else if (type == "timeout") { + this.handleErrorDestination(event, info, data, awsRequestId); + this.removeListener(awsRequestId, listener); + res.destroy(); + reject(data); + } else { + this.handleSuccessDestination(event, info, data, awsRequestId); + this.removeListener(awsRequestId, listener); + res.end(data); + resolve(res instanceof BufferedStreamResponse ? res : undefined); + } + break; default: + this.removeListener(awsRequestId, listener); + this.clear(); reject(new Error("Unknown error")); break; } - }); + } + this.on(awsRequestId, listener); }); return eventResponse; } catch (error) { throw error; } finally { this.#setLifetime(); + LambdaMock.#printExecTime(hrTimeStart, this.name); } } @@ -310,6 +371,35 @@ export class LambdaMock extends EventEmitter implements ILambdaMock { } }, runtimeLifetime); }; + #printStart = (awsRequestId: string, event: any, info?: any) => { + const method = event?.httpMethod ?? event?.method ?? ""; + let reqPath = event?.path ?? event?.rawPath ?? ""; + + if (reqPath) { + reqPath = decodeURI(reqPath); + } + const suffix = `${method} ${reqPath}`; + const date = new Date(); + + log.CYAN(`${date.toLocaleDateString()} ${date.toLocaleTimeString()} requestId: ${awsRequestId} | '${this.name}' ${suffix}`); + log.GREY(this.entryPoint); + let kind = ""; + if (typeof info?.kind == "string") { + kind = ` (${info.kind.toUpperCase()})`; + } + log.YELLOW(`input payload${kind}`); + log.info(event); + return process.hrtime(); + }; + static #printExecTime = (hrTimeStart: [number, number], lambdaName: string) => { + const endAt = process.hrtime(hrTimeStart); + const execTime = `${endAt[0]},${endAt[1]}s`; + const executedTime = `⌛️ '${lambdaName}' execution time: ${execTime}`; + // as main and worker process share the same stdout we need a timeout before printing any additionnal info + setTimeout(() => { + log.YELLOW(executedTime); + }, 400); + }; } -export type { ISnsEvent, IS3Event, ISqs, IDdbEvent, IDestination, LambdaEndpoint }; +export type { ISnsEvent, IS3Event, ISqs, IDdbEvent, IDestination, LambdaEndpoint, IDocumentDbEvent, IKinesisEvent }; diff --git a/src/lib/runtime/worker.ts b/src/lib/runtime/runners/node.ts similarity index 50% rename from src/lib/runtime/worker.ts rename to src/lib/runtime/runners/node.ts index adac8ed..0bac0d2 100644 --- a/src/lib/runtime/worker.ts +++ b/src/lib/runtime/runners/node.ts @@ -1,11 +1,15 @@ -const { parentPort, workerData } = require("worker_threads"); -import { log } from "../utils/colorize"; +import { parentPort, workerData } from "worker_threads"; +import { log } from "../../utils/colorize"; import inspector from "inspector"; +import { ResponseStream } from "../streamResponse"; +import type { WritableOptions } from "stream"; const debuggerIsAttached = inspector?.url() != undefined; +log.setDebug(workerData.debug); -let eventHandler: Function; +let eventHandler: Function & { stream?: boolean; streamOpt?: any }; const invalidResponse = new Error("Invalid response payload"); +const { AWS_LAMBDA_FUNCTION_NAME } = process.env; class Timeout extends Error { constructor(timeout: number, awsRequestId: string) { @@ -47,16 +51,16 @@ const genResponsePayload = (err: any) => { }; const returnError = (awsRequestId: string, err: any) => { - log.RED(`${awsRequestId}: Request failed`); + log.RED(`'${AWS_LAMBDA_FUNCTION_NAME}' | ${awsRequestId}: Request failed`); const data = genResponsePayload(err); - parentPort.postMessage({ channel: "fail", data, awsRequestId }); + parentPort!.postMessage({ channel: "fail", data, awsRequestId }); }; const returnResponse = (channel: string, awsRequestId: string, data: any) => { try { JSON.stringify(data); - parentPort.postMessage({ + parentPort!.postMessage({ channel, data, awsRequestId, @@ -66,7 +70,7 @@ const returnResponse = (channel: string, awsRequestId: string, data: any) => { } }; -parentPort.on("message", async (e: any) => { +parentPort!.on("message", async (e: any) => { const { channel, data, awsRequestId } = e; if (channel == "import") { @@ -82,13 +86,13 @@ parentPort.on("message", async (e: any) => { throw new Error(`${workerData.name} > ${workerData.handlerName} is not a function`); } - parentPort.postMessage({ channel: "import" }); + parentPort!.postMessage({ channel: "import" }); } else if (channel == "exec") { const { event, clientContext } = data; let isSent = false; let timeout = workerData.timeout * 1000; - + let streamRes: ResponseStream; const lambdaTimeoutInterval = setInterval(() => { timeout -= 250; @@ -96,7 +100,12 @@ parentPort.on("message", async (e: any) => { clearInterval(lambdaTimeoutInterval); if (!debuggerIsAttached) { isSent = true; - returnError(awsRequestId, new Timeout(workerData.timeout, awsRequestId)); + const tm = new Timeout(workerData.timeout, awsRequestId); + if (streamRes) { + streamRes.destroy(tm); + } else { + returnError(awsRequestId, tm); + } } } }, 250); @@ -110,39 +119,14 @@ parentPort.on("message", async (e: any) => { return timeout; }; let callbackWaitsForEmptyEventLoop = true; - const context = { + const commonContext = { get callbackWaitsForEmptyEventLoop() { return callbackWaitsForEmptyEventLoop; }, set callbackWaitsForEmptyEventLoop(val) { callbackWaitsForEmptyEventLoop = val; }, - succeed: (lambdaRes: any) => { - if (isSent) { - return; - } - resIsSent(); - returnResponse("succeed", awsRequestId, lambdaRes); - }, - fail: (err: any) => { - if (isSent) { - return; - } - resIsSent(); - returnError(awsRequestId, err); - }, - done: function (err: any, lambdaRes: any) { - if (isSent) { - return; - } - resIsSent(); - if (err) { - returnError(awsRequestId, err); - } else { - returnResponse("done", awsRequestId, lambdaRes); - } - }, functionVersion: "$LATEST", functionName: workerData.name, memoryLimitInMB: workerData.memorySize, @@ -155,33 +139,109 @@ parentPort.on("message", async (e: any) => { getRemainingTimeInMillis, }; - const callback = context.done; + if (typeof eventHandler.stream == "boolean") { + const streamWrite: WritableOptions["write"] = (chunk, encoding, next) => { + parentPort!.postMessage({ channel: "stream", data: chunk, awsRequestId, type: "write", encoding }); + next(); + }; + streamRes = new ResponseStream({ + highWaterMark: eventHandler.streamOpt?.highWaterMark, + write: streamWrite, + }); + + streamRes.on("close", () => { + if (!isSent) { + resIsSent(); + parentPort!.postMessage({ channel: "stream", awsRequestId, type: "end" }); + } + }); - // NOTE: this is a workaround for async versus callback lambda different behaviour - try { - const eventResponse = eventHandler(event, context, callback); + streamRes.on("error", (err: any) => { + if (err instanceof Timeout) { + const data = genResponsePayload(err); + parentPort!.postMessage({ channel: "stream", data, awsRequestId, type: "timeout" }); + } else if (!isSent) { + resIsSent(); + log.RED(err); + returnError(awsRequestId, err); + } + }); - eventResponse - ?.then?.((data: any) => { - clearInterval(lambdaTimeoutInterval); + streamRes.on("ct", (contentType: string) => { + return parentPort!.postMessage({ channel: "stream", data: contentType, awsRequestId, type: "ct" }); + }); + + const ret = eventHandler(event, streamRes, commonContext)?.catch?.((err: any) => { + streamRes.destroy(); + resIsSent(); + log.RED(err); + returnError(awsRequestId, err); + }); + + if (typeof ret?.then !== "function") { + resIsSent(); + streamRes.destroy(); + returnError(awsRequestId, new Error("Streaming does not support non-async handlers.")); + } + } else { + // NOTE: this is a workaround for async versus callback lambda different behaviour + + const context = { + ...commonContext, + succeed: (lambdaRes: any) => { if (isSent) { return; } resIsSent(); - returnResponse("return", awsRequestId, data); - }) - ?.catch((err: any) => { + returnResponse("succeed", awsRequestId, lambdaRes); + }, + fail: (err: any) => { + if (isSent) { + return; + } resIsSent(); returnError(awsRequestId, err); - }); + }, + done: function (err: any, lambdaRes: any) { + if (isSent) { + return; + } + resIsSent(); - if (typeof eventResponse?.then !== "function" && !isSent) { + if (err) { + returnError(awsRequestId, err); + } else { + returnResponse("done", awsRequestId, lambdaRes); + } + }, + }; + + const callback = context.done; + try { + const eventResponse = eventHandler(event, context, callback); + + eventResponse + ?.then?.((data: any) => { + clearInterval(lambdaTimeoutInterval); + if (isSent) { + return; + } + resIsSent(); + returnResponse("return", awsRequestId, data); + }) + ?.catch((err: any) => { + resIsSent(); + returnError(awsRequestId, err); + }); + + if (typeof eventResponse?.then !== "function" && !isSent) { + resIsSent(); + returnResponse("return", awsRequestId, null); + } + } catch (err) { resIsSent(); - returnResponse("return", awsRequestId, null); + returnError(awsRequestId, err); } - } catch (err) { - resIsSent(); - returnError(awsRequestId, err); } } }); diff --git a/src/lib/runtime/streamResponse.ts b/src/lib/runtime/streamResponse.ts new file mode 100644 index 0000000..3955921 --- /dev/null +++ b/src/lib/runtime/streamResponse.ts @@ -0,0 +1,188 @@ +import { Writable } from "stream"; +import type { WritableOptions } from "stream"; + +const invalidArg = (type: string, messages?: string[], cause?: any) => { + let msg = [`The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received ${type}`]; + + if (messages) { + msg = msg.concat(messages); + } + const err = new TypeError(msg.join("\n")); + if (cause) { + err.cause = cause; + } + return err; +}; +const invalidContentType = new TypeError('Invalid value "undefined" for header "Content-Type"'); + +const multipleEnd = new Error("write after end"); + +type writeCb = (error: Error | null | undefined) => void; +// custom Writable interace to exclude AWS's undefined properties +// currently TS's Omit dont handle this special case +// also added hidden properites like 'writable' whichs actually works on AWS + +interface IResponseStream { + cork: Writable["cork"]; + /** + * Do not use. This may lead to unexcepted result in AWS Lambda Runtime. + * @deprecated + */ + destroy: Writable["destroy"]; + end: (chunk?: string | Buffer | Uint8Array) => void; + uncork: Writable["uncork"]; + write: Writable["write"]; + addListener: Writable["addListener"]; + emit: Writable["emit"]; + eventNames: Writable["eventNames"]; + getMaxListeners: Writable["getMaxListeners"]; + listenerCount: Writable["listenerCount"]; + listeners: Writable["listeners"]; + off: Writable["off"]; + on: Writable["on"]; + once: Writable["once"]; + prependListener: Writable["prependListener"]; + prependOnceListener: Writable["prependOnceListener"]; + rawListeners: Writable["rawListeners"]; + removeAllListeners: Writable["removeAllListeners"]; + removeListener: Writable["removeListener"]; + setMaxListeners: Writable["setMaxListeners"]; + /** + * @param {any} contentType must be a string or implement toString() + * @throws Error + */ + setContentType: (contentType: any) => void; + + writable: boolean; + readonly writableEnded: boolean; + readonly writableFinished: boolean; + readonly writableHighWaterMark: number; + readonly writableLength: number; + readonly writableObjectMode: boolean; + readonly writableNeedDrain: boolean; + readonly writableCorked: number; + destroyed: boolean; + /** + * used by AWS inside HttpResponseStream.from function + * @internal + */ + _onBeforeFirstWrite?: (write: Writable["write"]) => any; +} + +interface IStreamableHandlerContext { + callbackwaitsforemptyeventloop: boolean; + functionVersion: string; + functionName: string; + memoryLimitInMB: string; + logGroupName: string; + logStreamName: string; + clientContext: any; + identity: any; + invokedFunctionArn: string; + awsRequestId: string; + getRemainingTimeInMillis: () => number; +} + +export type StreamableHandler = (event: any, responseStream: IResponseStream, context?: IStreamableHandlerContext) => Promise; + +interface IMetadata { + statusCode?: number; + headers?: { [key: string]: string }; + cookies?: string[]; + body?: string | Buffer; + isBase64Encoded?: boolean; +} + +export class ResponseStream extends Writable { + #isSent = false; + #isEnd = false; + #__write; + _onBeforeFirstWrite?: (write: Writable["write"]) => any; + constructor(opts: Partial) { + super({ highWaterMark: opts.highWaterMark, write: opts.write }); + this.#__write = this.write.bind(this); + + // @ts-ignore + this.write = (chunk: any, encoding?: BufferEncoding | writeCb, cb?: writeCb): boolean | undefined => { + chunk = this.#wrapeChunk(chunk); + + if (!this.#isSent && typeof this._onBeforeFirstWrite == "function") { + this._onBeforeFirstWrite((_chunk: any) => this.#__write(_chunk)); + } + // @ts-ignore + const writeResponse = this.#__write(chunk, encoding, cb); + + if (!this.#isSent) { + this.#isSent = true; + } + return writeResponse; + }; + + // @ts-ignore + this.end = (chunk: any) => { + if (this.#isEnd) { + throw multipleEnd; + } + // simple if(chunk) will not work as 0 must throw an error + const typeofChunk = typeof chunk; + if (chunk !== null && typeofChunk != "undefined" && typeofChunk !== "string" && !Buffer.isBuffer(chunk) && chunk?.constructor !== Uint8Array) { + throw invalidArg("an instance of Object", ["Try responseStream.write(yourObject);", "Then responseStream.end();"], chunk); + } + + if (typeofChunk != "undefined" && !this.#isSent && typeof this._onBeforeFirstWrite == "function") { + this._onBeforeFirstWrite((_chunk: any) => this.#__write(_chunk)); + } + + this.#isEnd = true; + + if (typeofChunk != "undefined") { + this.#__write(chunk); + } + + this.destroy(); + }; + } + #wrapeChunk = (chunk: any) => { + if (typeof chunk !== "string" && !Buffer.isBuffer(chunk) && chunk?.constructor !== Uint8Array) { + chunk = JSON.stringify(chunk); + } + return chunk; + }; + setContentType = (contentType: any) => { + if (!contentType) { + throw invalidContentType; + } + this.emit("ct", contentType); + }; +} + +export interface IHttpResponseStream { + /** + * @param {any} metadata http proxy integration response object. + * + * This could also be 'any' byte to be written before all write.() executions. + * + * Metadata will be wrapped into JSON.stringify(). + * + * `{statusCode: 404, cookies: ["hello=world"]}` + * + * + * @throws Error + */ + from: (responseStream: IResponseStream, metadata: IMetadata) => IResponseStream; +} + +export class HttpResponseStream { + public static from = (responseStream: IResponseStream, metadata: IMetadata) => { + const data = JSON.stringify(metadata); + + responseStream._onBeforeFirstWrite = (write) => { + responseStream.setContentType("application/vnd.awslambda.http-integration-response"); + write(data); + // Delimiter for http integration response content (metadata) and .write() content + write(new Uint8Array(8)); + }; + + return responseStream; + }; +} diff --git a/src/lib/server/daemon.ts b/src/lib/server/daemon.ts index b10a8de..96f2824 100644 --- a/src/lib/server/daemon.ts +++ b/src/lib/server/daemon.ts @@ -1,18 +1,15 @@ import http, { Server, IncomingMessage, ServerResponse } from "http"; import { AddressInfo } from "net"; import { networkInterfaces } from "os"; -import { Handlers, HttpMethod } from "./handlers"; -import { ILambdaMock, LambdaMock, LambdaEndpoint } from "../runtime/lambdaMock"; +import { Handlers } from "./handlers"; +import { ILambdaMock, LambdaMock } from "../runtime/rapidApi"; import { log } from "../utils/colorize"; -import { checkHeaders } from "../utils/checkHeaders"; import inspector from "inspector"; -import { html404, html500 } from "../utils/htmlStatusMsg"; +import { html404 } from "../../plugins/lambda/htmlStatusMsg"; import serveStatic from "serve-static"; import { randomUUID } from "crypto"; -import { invokeRequests } from "../../plugins/lambda/index"; - -const accountId = Buffer.from(randomUUID()).toString("hex").slice(0, 16); -const apiId = Buffer.from(randomUUID()).toString("ascii").slice(0, 10); +import { CommonEventGenerator } from "../../plugins/lambda/events/common"; +import { defaultServer } from "../../plugins/lambda/defaultServer"; let localIp: string; if (networkInterfaces) { @@ -31,84 +28,6 @@ if (debuggerIsAttached) { console.warn("Lambdas timeout are disabled when a Debugger is attached"); } -interface AlbEvent { - requestContext: { - elb: { - targetGroupArn: string; - }; - }; - multiValueHeaders?: { - [key: string]: string[]; - }; - - multiValueQueryStringParameters?: { - [key: string]: string[]; - }; - queryStringParameters?: { [key: string]: string }; - headers?: { [key: string]: any }; - httpMethod: string; - path: string; - isBase64Encoded: boolean; - body?: string; -} - -interface CommonApgEvent { - version: string; - body?: string; - queryStringParameters: { [key: string]: string }; - isBase64Encoded: boolean; - headers: { [key: string]: any }; - pathParameters?: { [key: string]: any }; -} - -type ApgHttpApiEvent = { - routeKey: string; - rawPath: string; - rawQueryString: string; - cookies?: string[]; - requestContext: { - accountId: string; - apiId: string; - domainName: string; - domainPrefix: string; - http: { - method: string; - path: string; - protocol: string; - sourceIp: string; - userAgent: string; - }; - requestId: string; - routeKey: string; - stage: string; - time: string; - timeEpoch: number; - }; -} & CommonApgEvent; - -type ApgHttpEvent = { - resource: string; - path: string; - httpMethod: string; - multiValueHeaders: { [key: string]: any }; - multiValueQueryStringParameters: { [key: string]: any }; - requestContext: { - accountId: string; - apiId: string; - domainName: string; - domainPrefix: string; - extendedRequestId: string; - httpMethod: string; - path: string; - protocol: string; - requestId: string; - requestTime: string; - requestTimeEpoch: number; - resourcePath: string; - stage: string; - }; -} & CommonApgEvent; - interface IDaemonConfig { debug: boolean; } @@ -120,18 +39,41 @@ export class Daemon extends Handlers { method?: string | string[]; filter: RegExp | string; callback: (req: any, res: any) => Promise | any | undefined; - }[] = [invokeRequests]; + }[] = []; onReady?: (port: number, ip: string) => any; stop(cb: (err?: any) => void) { this.#server.close(cb); } constructor(config: IDaemonConfig = { debug: false }) { super(config); + log.setDebug(config.debug); + // @ts-ignore + globalThis.sco = []; this.#server = http.createServer({ maxHeaderSize: 105536 }, this.#requestListener.bind(this)); + this.#server.on("connection", (socket) => { + socket.on("close", () => { + // @ts-ignore + const connectionIndex = globalThis.sco.findIndex((x) => x == socket); + if (connectionIndex != -1) { + // @ts-ignore + globalThis.sco.splice(connectionIndex, 1); + } + }); + // @ts-ignore + globalThis.sco.push(socket); + }); + const uuid = randomUUID(); + CommonEventGenerator.accountId = Buffer.from(uuid).toString("hex").slice(0, 16); + CommonEventGenerator.apiId = Buffer.from(uuid).toString("ascii").slice(0, 10); + CommonEventGenerator.port = this.port; } get port() { return Handlers.PORT; } + set port(p) { + Handlers.PORT = p; + CommonEventGenerator.port = p; + } set serve(root: string) { this.#serve = serveStatic(root); @@ -151,7 +93,7 @@ export class Daemon extends Handlers { this.#server.listen(port, async () => { const { port: listeningPort, address } = this.#server.address() as AddressInfo; - Handlers.PORT = listeningPort; + this.port = listeningPort; if (localIp) { Handlers.ip = localIp; } @@ -170,7 +112,7 @@ export class Daemon extends Handlers { }); } - #findCustomOfflineRequest(method: string, pathname: string) { + #findRequestHandler(method: string, pathname: string) { pathname = pathname.endsWith("/") ? pathname : `${pathname}/`; const foundCustomCallback = this.customOfflineRequests.find((x) => { let validPath = false; @@ -199,13 +141,13 @@ export class Daemon extends Handlers { } } async #requestListener(req: IncomingMessage, res: ServerResponse) { - const { url, method, headers, rawHeaders } = req; + const { url, method } = req; const parsedURL = new URL(url as string, "http://localhost:3003"); - const customCallback = this.#findCustomOfflineRequest(method!, parsedURL.pathname); + const customCallback = this.#findRequestHandler(method!, parsedURL.pathname); if (customCallback) { - //SECTION: Route provided by client in config file and/or by plugins + //SECTION: Route @invoke, @url and other routes provided by plugins try { await customCallback(req, res); } catch (err) { @@ -214,459 +156,28 @@ export class Daemon extends Handlers { } } } else { - //SECTION: ALB and APG server - - const { searchParams } = parsedURL; - const multiValueHeaders = this.#getMultiValueHeaders(rawHeaders); - - let body = Buffer.alloc(0); - let requestMockType: string | undefined | null = undefined; - - if (parsedURL.searchParams.get("x_mock_type") !== null) { - requestMockType = parsedURL.searchParams.get("x_mock_type"); - } else if (headers["x-mock-type"]) { - if (Array.isArray(headers["x-mock-type"])) { - requestMockType = headers["x-mock-type"][0]; - } else { - requestMockType = headers["x-mock-type"]; - } - } + // fallback to ALB and APG server - const lambdaController = this.getHandler({ - headers: multiValueHeaders, - query: searchParams, - method: method as HttpMethod, - path: decodeURIComponent(parsedURL.pathname), - kind: requestMockType, + req.on("error", (err) => { + console.error(err.stack); }); - if (lambdaController) { - const mockEvent = lambdaController.event; - - try { - checkHeaders(headers, mockEvent.kind); - } catch (error: any) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/html"); - return res.end(error.message); - } - // TODO: create a class which handles the request and response parsing/checking based on event kind - let event = - mockEvent.kind == "alb" - ? this.#convertReqToAlbEvent(req, mockEvent, multiValueHeaders) - : this.#convertReqToApgEvent(req, mockEvent, lambdaController.handler.outName, multiValueHeaders); - - req - .on("data", (chunk) => { - body += chunk; - }) - .on("end", async () => { - const isBase64 = headers["content-type"]?.includes("multipart/form-data"); - event.body = body.length ? body.toString() : mockEvent.kind == "alb" ? "" : undefined; + const foundLambda = await defaultServer(req, res, parsedURL); - if (isBase64 && event.body) { - event.body = Buffer.from(event.body).toString("base64"); - } - - if (this.debug) { - log.YELLOW(`${mockEvent.kind.toUpperCase()} event`); - console.log(event); - } - this.#responseHandler(res, event, lambdaController.handler, method as HttpMethod, parsedURL.pathname, mockEvent); - }) - .on("error", (err) => { - console.error(err.stack); - }); - } else if (this.#serve) { - this.#serve(req, res, () => { - res.setHeader("Content-Type", "text/html"); - res.statusCode = 404; - res.end(html404); - }); - } else { + const notFound = () => { res.setHeader("Content-Type", "text/html"); res.statusCode = 404; res.end(html404); - } - } - } - - async #responseHandler(res: ServerResponse, event: any, lambdaController: ILambdaMock, method: HttpMethod, path: string, mockEvent: LambdaEndpoint) { - if (this.debug) { - const hrTimeStart = process.hrtime(); - - res.on("close", () => { - const endAt = process.hrtime(hrTimeStart); - const execTime = `${endAt[0]},${endAt[1]}s`; - const executedTime = `⌛️ '${lambdaController.name}' execution time: ${execTime}`; - // NOTE: as main and worker process share the same stdout we need a timeout before printing any additionnal info - setTimeout(() => { - log.YELLOW(executedTime); - }, 400); - }); - } - - try { - const date = new Date(); - const awsRequestId = randomUUID(); - log.CYAN(`${date.toLocaleDateString()} ${date.toLocaleTimeString()} requestId: ${awsRequestId} | '${lambdaController.name}' ${method} ${path}`); - - if (mockEvent.async) { - res.statusCode = 200; - res.end(); - } - const responseData = await lambdaController.invoke(event, mockEvent); - if (!res.writableFinished) { - this.#setResponseHead(res, responseData, mockEvent); - if (!res.writableFinished) { - this.#writeResponseBody(res, responseData, mockEvent.kind); - } - } - } catch (error) { - if (!res.writableFinished) { - res.statusCode = 500; - res.setHeader("Content-Type", "text/html"); - res.end(html500); - } - } - } - - #setResponseHead(res: ServerResponse, responseData: any, mockEvent: LambdaEndpoint) { - if (mockEvent.kind == "alb") { - res.setHeader("Server", "awselb/2.0"); - const { statusDescription } = responseData; - if (typeof responseData.statusCode == "number" && typeof statusDescription == "string") { - const descComponents = statusDescription.split(" "); - if (isNaN(descComponents[0] as unknown as number)) { - log.RED("statusDescription must start with a statusCode number followed by a space + status description text"); - log.YELLOW("example: '200 Found'"); - } else { - const desc = descComponents.slice(1).join(" "); - if (desc.length) { - res.statusMessage = desc; - } - } - } - } else if (mockEvent.kind == "apg") { - res.setHeader("Apigw-Requestid", Buffer.from(randomUUID()).toString("base64").slice(0, 16)); - } - - res.setHeader("Date", new Date().toUTCString()); - - if (responseData) { - if (mockEvent.kind == "alb") { - if (mockEvent.multiValueHeaders) { - if (responseData.multiValueHeaders) { - const headersKeys = Object.keys(responseData.multiValueHeaders).filter((key) => key !== "Server" && key !== "Apigw-Requestid" && key !== "Date"); - headersKeys.forEach((key) => { - if (Array.isArray(responseData.multiValueHeaders[key])) { - res.setHeader(key, responseData.multiValueHeaders[key]); - } else { - log.RED("multiValueHeaders values must be an array"); - log.YELLOW("example:"); - log.GREEN("'Content-Type': ['application/json']"); - } - }); - } else if (responseData.headers) { - log.RED("An ALB Lambda with 'multiValueHeaders enabled' must return 'multiValueHeaders' instead of 'headers'"); - res.statusCode = 502; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(html500); - } - } else { - if (typeof responseData.headers == "object" && !Array.isArray(responseData.headers)) { - const headersKeys = Object.keys(responseData.headers).filter((key) => key !== "Server" && key !== "Apigw-Requestid" && key !== "Date"); - headersKeys.forEach((key) => { - res.setHeader(key, responseData.headers[key]); - }); - } - } - } else { - if (typeof responseData.headers == "object" && !Array.isArray(responseData.headers)) { - const headersKeys = Object.keys(responseData.headers).filter((key) => key !== "Server" && key !== "Apigw-Requestid" && key !== "Date"); - headersKeys.forEach((key) => { - res.setHeader(key, responseData.headers[key]); - }); - } - - if (mockEvent.version == 1 && responseData.multiValueHeaders) { - const headersKeys = Object.keys(responseData.multiValueHeaders).filter((key) => key !== "Server" && key !== "Apigw-Requestid" && key !== "Date"); - headersKeys.forEach((key) => { - if (Array.isArray(responseData.multiValueHeaders[key])) { - res.setHeader(key, responseData.multiValueHeaders[key]); - } else { - log.RED("multiValueHeaders values must be an array"); - log.YELLOW("example:"); - log.GREEN("'Content-Type': ['application/json']"); - } - }); - } - } - - if (!responseData.statusCode) { - if (mockEvent.kind == "alb") { - log.RED("Invalid 'statusCode'.\nALB Lambdas must return a valid 'statusCode' number"); - res.statusCode = 502; - res.setHeader("Content-Type", "text/html"); - res.end(html500); - } else { - res.statusCode = 200; - } - } else { - if (mockEvent.kind == "alb") { - if (typeof responseData.statusCode == "number") { - res.statusCode = responseData.statusCode; - } else { - log.RED("Invalid 'statusCode'.\nALB Lambdas must return a valid 'statusCode' number"); - res.statusCode = 502; - } - } else { - res.statusCode = responseData.statusCode; - } - - if (responseData.cookies?.length) { - if (mockEvent.version == 2) { - res.setHeader("Set-Cookie", responseData.cookies); - } else { - log.RED(`'cookies' as return value is supported only in API Gateway HTTP API (httpApi).\nUse 'Set-Cookie' header instead`); - } - } - } - } else { - res.statusCode = mockEvent.kind == "alb" ? 502 : 200; - } - } + }; - #writeResponseBody(res: ServerResponse, responseData: any, mockEvent: string) { - let resContent = ""; - if (responseData) { - if (typeof responseData.body == "string") { - resContent = responseData.body; - } else if (responseData.body) { - console.log("response 'body' must be a string. Receievd", typeof responseData.body); - } else { - if (mockEvent == "apg") { - res.setHeader("Content-Type", "application/json"); - if (typeof responseData == "string") { - resContent = responseData; - } else if (typeof responseData == "object") { - resContent = JSON.stringify(responseData); - } + if (!foundLambda) { + if (this.#serve) { + this.#serve(req, res, notFound); } else { - res.setHeader("Content-Type", "application/octet-stream"); + notFound(); } } } - res.end(resContent); - } - - #getMultiValueHeaders = (rawHeaders: string[]) => { - let multiValueHeaders: any = {}; - const multiKeys = rawHeaders.filter((x, i) => i % 2 == 0).map((x) => x.toLowerCase()); - const multiValues = rawHeaders.filter((x, i) => i % 2 !== 0); - - multiKeys.forEach((x, i) => { - if (x == "x-mock-type") { - return; - } - if (multiValueHeaders[x]) { - multiValueHeaders[x].push(multiValues[i]); - } else { - multiValueHeaders[x] = [multiValues[i]]; - } - }); - - return multiValueHeaders; - }; - #convertReqToAlbEvent(req: IncomingMessage, mockEvent: LambdaEndpoint, multiValueHeaders: { [key: string]: string[] }) { - const { method, headers, url } = req; - - const parsedURL = new URL(url as string, "http://localhost:3003"); - - let event: Partial = { - requestContext: { - elb: { - targetGroupArn: "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a", - }, - }, - httpMethod: method as string, - path: parsedURL.pathname, - isBase64Encoded: false, - }; - - if (mockEvent.multiValueHeaders) { - event.multiValueHeaders = { - "x-forwarded-for": [String(req.socket.remoteAddress)], - "x-forwarded-proto": ["http"], - "x-forwarded-port": [String(this.port)], - ...multiValueHeaders, - }; - - event.multiValueQueryStringParameters = {}; - - const parsedURL = new URL(url as string, "http://localhost:3003"); - parsedURL.searchParams.delete("x_mock_type"); - - for (const k of Array.from(new Set(parsedURL.searchParams.keys()))) { - event.multiValueQueryStringParameters[k] = parsedURL.searchParams.getAll(k).map(encodeURI); - } - } else { - event.headers = { - "x-forwarded-for": req.socket.remoteAddress, - "x-forwarded-proto": "http", - "x-forwarded-port": this.port, - ...headers, - }; - event.queryStringParameters = this.#paramsToAlbObject(url as string); - - if (event.headers["x-mock-type"]) { - delete event.headers["x-mock-type"]; - } - if (headers["content-type"]?.includes("multipart/form-data")) { - event.isBase64Encoded = true; - } - } - return event; - } - - #convertReqToApgEvent(req: IncomingMessage, mockEvent: LambdaEndpoint, lambdaName: string, multiValueHeaders: { [key: string]: string[] }): ApgHttpApiEvent | ApgHttpEvent { - const { method, headers, url } = req; - - const parsedURL = new URL(url as string, "http://localhost:3003"); - parsedURL.searchParams.delete("x_mock_type"); - - const paramDeclarations = mockEvent.paths[0].split("/"); - const reqParams = parsedURL.pathname.split("/"); - - let pathParameters: any = {}; - - paramDeclarations.forEach((k, i) => { - if (k.startsWith("{") && k.endsWith("}") && !k.endsWith("+}")) { - pathParameters[k.slice(1, -1)] = reqParams[i]; - } - }); - - const customHeaders: any = { "x-forwarded-for": req.socket.remoteAddress, "x-forwarded-proto": "http", "x-forwarded-port": this.port, ...headers }; - - let event: any; - if (mockEvent.version == 1) { - const multiValueQueryStringParameters: any = {}; - - for (const k of Array.from(new Set(parsedURL.searchParams.keys()))) { - multiValueQueryStringParameters[k] = parsedURL.searchParams.getAll(k); - } - const mergedMultiValueHeaders = { - "x-forwarded-for": [String(req.socket.remoteAddress)], - "x-forwarded-proto": ["http"], - "x-forwarded-port": [String(this.port)], - ...multiValueHeaders, - }; - const apgEvent: ApgHttpEvent = { - version: "1.0", - resource: `/${lambdaName}`, - path: parsedURL.pathname, - httpMethod: method!, - headers: customHeaders, - multiValueHeaders: mergedMultiValueHeaders, - // @ts-ignore - queryStringParameters: Object.fromEntries(parsedURL.searchParams), - multiValueQueryStringParameters, - requestContext: { - accountId: String(accountId), - apiId: apiId, - domainName: `localhost:${this.port}`, - domainPrefix: "localhost", - extendedRequestId: "fake-id", - path: parsedURL.pathname, - protocol: "HTTP/1.1", - httpMethod: method!, - resourcePath: `/${lambdaName}`, - requestId: "", - requestTime: new Date().toISOString(), - requestTimeEpoch: Date.now(), - stage: "$local", - }, - isBase64Encoded: false, - }; - if (Object.keys(pathParameters).length) { - apgEvent.pathParameters = pathParameters; - } - event = apgEvent; - } else { - const customMethod = mockEvent.methods.find((x) => x == method) ?? "ANY"; - - let queryStringParameters: any = {}; - let rawQueryString = ""; - for (const k of Array.from(new Set(parsedURL.searchParams.keys()))) { - const values = parsedURL.searchParams.getAll(k); - - rawQueryString += `&${values.map((x) => encodeURI(`${k}=${x}`)).join("&")}`; - queryStringParameters[k] = values.join(","); - } - if (rawQueryString) { - rawQueryString = rawQueryString.slice(1); - } - - const apgEvent: ApgHttpApiEvent = { - version: "2.0", - routeKey: `${customMethod} ${parsedURL.pathname}`, - rawPath: parsedURL.pathname, - rawQueryString, - headers: customHeaders, - queryStringParameters, - isBase64Encoded: false, - requestContext: { - accountId: String(accountId), - apiId: apiId, - domainName: `localhost:${this.port}`, - domainPrefix: "localhost", - http: { - method: method as string, - path: parsedURL.pathname, - protocol: "HTTP/1.1", - sourceIp: "127.0.0.1", - userAgent: headers["user-agent"] ?? "", - }, - requestId: "", - routeKey: `${method} ${parsedURL.pathname}`, - stage: "$local", - time: new Date().toISOString(), - timeEpoch: Date.now(), - }, - }; - if (Object.keys(pathParameters).length) { - apgEvent.pathParameters = pathParameters; - } - if (headers.cookie) { - apgEvent.cookies = headers.cookie.split("; "); - } - - event = apgEvent; - } - if (event.headers["x-mock-type"]) { - delete event.headers["x-mock-type"]; - } - if (headers["content-type"]?.includes("multipart/form-data")) { - event.isBase64Encoded = true; - } - return event; - } - - #paramsToAlbObject(reqUrl: string) { - const queryStartIndex = reqUrl.indexOf("?"); - if (queryStartIndex == -1) return {}; - - let queryStringComponents: any = {}; - const queryString = reqUrl.slice(queryStartIndex + 1); - const queryComponents = queryString.split("&"); - - queryComponents.forEach((c) => { - const [key, value] = c.split("="); - queryStringComponents[key] = value; - }); - - delete queryStringComponents.x_mock_type; - return queryStringComponents; } async load(lambdaDefinitions: ILambdaMock[]) { diff --git a/src/lib/server/handlers.ts b/src/lib/server/handlers.ts index 8e9cc5e..11aefea 100644 --- a/src/lib/server/handlers.ts +++ b/src/lib/server/handlers.ts @@ -1,6 +1,12 @@ -import { ILambdaMock, LambdaEndpoint } from "../runtime/lambdaMock"; +import { ILambdaMock, LambdaEndpoint } from "../runtime/rapidApi"; +import { log } from "../utils/colorize"; +import type { normalizedSearchParams } from "../../plugins/lambda/events/common"; export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "ANY"; +const customInvokeUrls = ["@invoke", "@url"]; + +const invalidParams = (lambdaName: string) => log.YELLOW(`Invalid Request Headers or query string for ${lambdaName}`); +// const canMatchWithTrailingSlash = (reqPath: string, declaredPath: string) => {}; export class Handlers { static handlers: ILambdaMock[] = []; @@ -14,7 +20,7 @@ export class Handlers { static parseNameFromUrl(lambdaName: string) { const components = lambdaName.split("/"); - let name = components[1] == "@invoke" ? components[2] : components[3]; + let name = customInvokeUrls.includes(components[1]) ? components[2] : components[3]; name = decodeURIComponent(name); if (name.includes(":function")) { const arnComponent = name.split(":"); @@ -30,10 +36,49 @@ export class Handlers { const name = Handlers.parseNameFromUrl(lambdaName); return Handlers.handlers.find((x) => x.name == name || x.outName == name); } - getHandler({ method, path, kind, headers, query }: { method: HttpMethod; path: string; headers: { [key: string]: string[] }; kind?: string | null; query: URLSearchParams }) { - const hasNotWilcard = !path.includes("*"); - const hasNotBrackets = !path.includes("{") && !path.includes("}"); + static #matchAlbQuery = (query: LambdaEndpoint["query"], reqQuery: normalizedSearchParams) => { + const matchesAll: boolean[] = []; + + const queryAsString = reqQuery.toString(); + query!.forEach((q) => { + const matches: boolean = q.some(({ Key, Value }) => { + if (Key) { + const keysValues = reqQuery[Key.toLowerCase()]; + if (!Value) { + log.RED("alb conditions query must have 'Value' when 'Key' is specified"); + return false; + } + if (keysValues) { + return keysValues.includes(Value.toLowerCase()); + } else { + return false; + } + } else if (Value) { + // in AWS when no Key is provided, Value = URLSearchParams's Key + return queryAsString == Value.toLowerCase(); + } + }); + + matchesAll.push(matches); + }); + + return matchesAll.every((x) => x === true); + }; + + static findHandler = ({ + method, + path, + kind, + headers, + query, + }: { + method: HttpMethod; + path: string; + headers: { [key: string]: string[] }; + kind?: string | null; + query: normalizedSearchParams; + }) => { let foundLambda: { event: LambdaEndpoint; handler: ILambdaMock } | undefined; const kindToLowerCase = kind?.toLowerCase(); @@ -42,39 +87,45 @@ export class Handlers { .filter((e) => (kind ? e.kind == kindToLowerCase : e)) .find((w) => { if (w.kind == "apg") { - const isValidApgEvent = hasNotBrackets && w.paths.includes(path) && (w.methods.includes("ANY") || w.methods.includes(method)); + const matchsPath = w.paths.includes(path); + // if (!matchsPath) { + // const canMatch = canMatchWithTrailingSlash(path, w.paths[0]); + // } + const isValidApgEvent = matchsPath && (w.methods.includes("ANY") || w.methods.includes(method)); if (isValidApgEvent) { - foundLambda = { - event: w, - handler: x, - }; + const matches: boolean[] = [isValidApgEvent]; + + if (w.headers) { + matches.push(w.headers.every((h) => h in headers)); + } + if (w.querystrings) { + matches.push(w.querystrings.every((q) => q in query)); + } + + const matchesAll = matches.every((x) => x === true); + + if (matchesAll) { + foundLambda = { + event: w, + handler: x, + }; + } else { + invalidParams(x.name); + } + return matchesAll; } - return isValidApgEvent; } - const isValidAlbEvent = hasNotWilcard && w.paths.includes(path) && (w.methods.includes("ANY") || w.methods.includes(method)); + const isValidAlbEvent = w.paths.includes(path) && (w.methods.includes("ANY") || w.methods.includes(method)); if (isValidAlbEvent) { const matches: boolean[] = [isValidAlbEvent]; if (w.query) { - const hasRequiredQueryString = Object.keys(w.query).some((k) => { - const value = query.get(k); - - return typeof value == "string" && value == w.query![k]; - }); - - matches.push(hasRequiredQueryString); + matches.push(Handlers.#matchAlbQuery(w.query, query)); } if (w.header) { - const foundHeader = headers[w.header.name.toLowerCase()]; - - if (foundHeader) { - const hasRequiredHeader = w.header.values.some((v) => foundHeader.find((val) => val == v)); - - matches.push(hasRequiredHeader); - } else { - matches.push(false); - } + const matchsAllHeaders = w.header.every((x) => headers[x.name.toLowerCase()] && x.values.some((v) => headers[x.name.toLowerCase()].find((s) => s == v))); + matches.push(matchsAllHeaders); } const matchesAll = matches.every((x) => x === true); @@ -84,6 +135,8 @@ export class Handlers { event: w, handler: x, }; + } else { + invalidParams(x.name); } return matchesAll; @@ -95,18 +148,23 @@ export class Handlers { return foundLambda; } else { // Use Regex to find lambda controller + const pathComponentsLength = path.split("/").filter(Boolean).length; const foundHandler = Handlers.handlers.find((x) => x.endpoints .filter((e) => (kind ? e.kind == kindToLowerCase : e)) .find((w) => { - const hasPath = w.paths.find((p) => { - const AlbAnyPathMatch = p.replace(/\*/g, ".*").replace(/\//g, "\\/"); - const ApgPathPartMatch = p.replace(/\{[\w.:-]+\+?\}/g, ".*").replace(/\//g, "\\/"); - - const AlbPattern = new RegExp(`^${AlbAnyPathMatch}$`, "g"); - const ApgPattern = new RegExp(`^${ApgPathPartMatch}$`, "g"); + const hasPath = w.paths.find((p, i) => { + const isValid = path.match(w.pathsRegex[i]); + if (w.kind == "alb") { + return isValid; + } else { + const hasPlus = p.includes("+") || p == "/*"; - return (w.kind == "alb" && hasNotWilcard && AlbPattern.test(path)) || (w.kind == "apg" && hasNotBrackets && ApgPattern.test(path)); + if (hasPlus) { + return isValid; + } + return isValid && p.split("/").filter(Boolean).length == pathComponentsLength; + } }); const isValidEvent = hasPath && (w.methods.includes("ANY") || w.methods.includes(method)); @@ -115,25 +173,19 @@ export class Handlers { if (w.kind == "alb") { if (w.query) { - const hasRequiredQueryString = Object.keys(w.query).some((k) => { - const value = query.get(k); - - return typeof value == "string" && value == w.query![k]; - }); - - matches.push(hasRequiredQueryString); + matches.push(Handlers.#matchAlbQuery(w.query, query)); } if (w.header) { - const foundHeader = headers[w.header.name.toLowerCase()]; - - if (foundHeader) { - const hasRequiredHeader = w.header.values.some((v) => foundHeader.find((val) => val == v)); - - matches.push(hasRequiredHeader); - } else { - matches.push(false); - } + const matchsAllHeaders = w.header.every((x) => headers[x.name.toLowerCase()] && x.values.some((v) => headers[x.name.toLowerCase()].find((s) => s == v))); + matches.push(matchsAllHeaders); + } + } else { + if (w.headers) { + matches.push(w.headers.every((h) => h in headers)); + } + if (w.querystrings) { + matches.push(w.querystrings.every((q) => q in query)); } } @@ -143,6 +195,8 @@ export class Handlers { event: w, handler: x, }; + } else { + invalidParams(x.name); } return matchesAll; @@ -154,7 +208,7 @@ export class Handlers { return foundLambda; } } - } + }; addHandler(lambdaController: ILambdaMock) { const foundIndex = Handlers.handlers.findIndex((x) => x.name == lambdaController.name && x.esOutputPath == lambdaController.esOutputPath); @@ -164,13 +218,17 @@ export class Handlers { if (this.debug) { if (lambdaController.endpoints.length) { lambdaController.endpoints.forEach((x) => { + const color = x.kind == "alb" ? "36" : "35"; x.paths.forEach((p) => { - this.#printPath(x.methods.join(" "), p); + this.#printPath(x.methods.join(" "), p, color); }); }); } else { this.#printPath("ANY", `/@invoke/${lambdaController.name}`); } + if (lambdaController.url) { + this.#printPath("ANY", `/@url/${lambdaController.name}`, "34"); + } } } else { // @ts-ignore @@ -179,8 +237,8 @@ export class Handlers { } } - #printPath(method: string, path: string) { + #printPath(method: string, path: string, color: string = "90") { const printingString = `${method}\thttp://localhost:${Handlers.PORT}${path}`; - console.log(`\x1b[36m${printingString}\x1b[0m`); + console.log(`\x1b[${color}m${printingString}\x1b[0m`); } } diff --git a/src/lib/utils/amazonifyHeaders.ts b/src/lib/utils/amazonifyHeaders.ts new file mode 100644 index 0000000..e7aa954 --- /dev/null +++ b/src/lib/utils/amazonifyHeaders.ts @@ -0,0 +1,40 @@ +const fixCookiePath = (cookie: string) => { + const components = cookie.split(";"); + const foundPathIndex = components.findIndex((x) => x.toLowerCase().startsWith("path=")); + + if (foundPathIndex == -1) { + components.push("Path=/"); + } + return components.join(";"); +}; +export const amazonifyHeaders = (_headers?: { [key: string]: string | any[] }, cookies?: string[]) => { + let headers: any = {}; + if (_headers) { + Object.entries(_headers).forEach(([key, value]) => { + headers[key.toLowerCase()] = Array.isArray(value) ? `[${value.join(", ")}]` : value; + }); + } + + if (Array.isArray(cookies)) { + if (!cookies.every((x) => typeof x == "string")) { + // @ts-ignore + const err = new Error("Wrong 'cookies'. must be string[]", { cause: cookies }); + let returnHeaders: any = { + "Content-Type": headers["content-type"] ?? "application/json", + "x-amzn-ErrorType": "InternalFailure", + }; + if (headers["set-cookie"]) { + returnHeaders["set-cookie"] = headers["set-cookie"]; + } + // @ts-ignore + err.headers = returnHeaders; + + throw err; + } + + cookies = cookies.map(fixCookiePath); + headers["set-cookie"] = headers["set-cookie"] ? [headers["set-cookie"], ...cookies] : cookies; + } + + return headers; +}; diff --git a/src/lib/utils/checkHeaders.ts b/src/lib/utils/checkHeaders.ts deleted file mode 100644 index e29857c..0000000 --- a/src/lib/utils/checkHeaders.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { headerTooLarge, badRequest } from "./htmlStatusMsg"; - -const headerError = new Error(headerTooLarge); - -const maxHeaderSize = { - alb: 65536, - apg: 10240, -}; - -const singleAlbHeaderSize = 16376; - -export const checkHeaders = (headers: { [key: string]: any }, kind: "alb" | "apg") => { - if (!headers.host) { - throw new Error(badRequest); - } - let total = 0; - const maximumAllowedSize = maxHeaderSize[kind]; - const entries = Object.entries(headers); - - if (kind == "alb") { - entries.forEach((entry) => { - const [k, v] = entry; - if (v == "x-mock-type") { - return; - } - const headerLength = k.length + v.length; - if (headerLength > singleAlbHeaderSize) { - throw headerError; - } - - total = total + headerLength; - }); - } else { - entries.forEach((entry) => { - const [k, v] = entry; - if (v == "x-mock-type") { - return; - } - total = total + k.length + v.length; - }); - } - if (total > maximumAllowedSize) { - throw headerError; - } -}; diff --git a/src/lib/utils/colorize.ts b/src/lib/utils/colorize.ts index b2b3832..5c92345 100644 --- a/src/lib/utils/colorize.ts +++ b/src/lib/utils/colorize.ts @@ -1,16 +1,22 @@ -const GREEN = (s: string) => { - console.log(`\x1b[32m${s}\x1b[0m`); +let debug = false; + +const setDebug = (_debug: boolean) => { + debug = _debug; }; -const YELLOW = (s: string) => { - console.log(`\x1b[33m${s}\x1b[0m`); +const getDebug = () => debug; + +const print = (color: string, s: string) => { + if (debug) { + console.log(`\x1b[${color}m${s}\x1b[0m`); + } }; -const RED = (s: string) => { - console.log(`\x1b[31m${s}\x1b[0m`); -}; -const CYAN = (s: string) => { - console.log(`\x1b[36m${s}\x1b[0m`); -}; -const BR_BLUE = (s: string) => { - console.log(`\x1b[94m${s}\x1b[0m`); -}; -export const log = { GREEN, YELLOW, CYAN, BR_BLUE, RED }; + +const RED = (s: string) => print("31", s); +const GREEN = (s: string) => print("32", s); +const YELLOW = (s: string) => print("33", s); +const CYAN = (s: string) => print("36", s); +const GREY = (s: string) => print("90", s); +const BR_BLUE = (s: string) => print("94", s); +const info = (s: any) => (debug ? console.log(s) : void 0); + +export const log = { GREEN, YELLOW, CYAN, BR_BLUE, RED, GREY, setDebug, getDebug, info }; diff --git a/src/lib/utils/readDefineConfig.ts b/src/lib/utils/readDefineConfig.ts new file mode 100644 index 0000000..5b3d203 --- /dev/null +++ b/src/lib/utils/readDefineConfig.ts @@ -0,0 +1,40 @@ +import path from "path"; +import { log } from "./colorize"; + +const jsExts = [".js", ".cjs", ".mjs"]; +const tsExts = [".ts", ".mts"]; + +export const readDefineConfig = async (config: string) => { + if (!config || typeof config !== "string") { + return; + } + + const parsed = path.posix.parse(config); + + const customFilePath = path.posix.join(parsed.dir, parsed.name); + const configObjectName = parsed.ext.slice(1); + const configPath = path.posix.resolve(customFilePath); + + let exportedFunc; + let err; + if (jsExts.includes(parsed.ext)) { + exportedFunc = await import(`file://${configPath}${parsed.ext}`); + } else if (tsExts.includes(parsed.ext)) { + throw new Error("TypeScript 'defineConfig' is not supported"); + } else { + for (const ext of jsExts) { + try { + exportedFunc = await import(`file://${configPath}${ext}`); + break; + } catch (error) { + err = error; + } + } + } + + if (!exportedFunc) { + log.YELLOW("Can not read defineConfig"); + log.info(err); + } + return { exportedFunc, configObjectName, configPath }; +}; diff --git a/src/lib/utils/zip.ts b/src/lib/utils/zip.ts index 0e6907e..e1c517d 100644 --- a/src/lib/utils/zip.ts +++ b/src/lib/utils/zip.ts @@ -8,8 +8,9 @@ export interface IZipOptions { zipName: string; include?: string[]; sourcemap?: boolean | string; + format: string; } -export const zip = ({ filePath, zipName, include, sourcemap }: IZipOptions) => { +export const zip = ({ filePath, zipName, include, sourcemap, format }: IZipOptions) => { return new Promise(async (resolve) => { const archive = archiver("zip", { zlib: { level: 9 }, @@ -36,6 +37,10 @@ export const zip = ({ filePath, zipName, include, sourcemap }: IZipOptions) => { } catch (error) {} } + if (format == "esm") { + archive.append('{"type":"module"}', { name: "package.json" }); + } + if (include && include.every((x) => typeof x == "string")) { for (const file of include) { const includPath = path.resolve(file); diff --git a/src/plugins/lambda/defaultServer/index.ts b/src/plugins/lambda/defaultServer/index.ts new file mode 100644 index 0000000..701f673 --- /dev/null +++ b/src/plugins/lambda/defaultServer/index.ts @@ -0,0 +1,91 @@ +import { IncomingMessage, IncomingHttpHeaders, ServerResponse } from "http"; +import { CommonEventGenerator } from "../events/common"; +import { Handlers } from "../../../lib/server/handlers"; +import { BufferedStreamResponse } from "../../../lib/runtime/bufferedStreamResponse"; +import { collectBody, checkHeaders } from "../utils"; +import { AlbRequestHandler } from "../events/alb"; +import { ApgRequestHandler } from "../events/apg"; +import { randomUUID } from "crypto"; +import type { HttpMethod } from "../../../lib/server/handlers"; +import type { LambdaEndpoint } from "../../../lib/parseEvents/endpoints"; + +const getRequestMockType = (searchParams: URLSearchParams, headers: IncomingHttpHeaders) => { + if (searchParams.get("x_mock_type") !== null) { + return searchParams.get("x_mock_type"); + } else if (headers["x-mock-type"]) { + if (Array.isArray(headers["x-mock-type"])) { + return headers["x-mock-type"][0]; + } else { + return headers["x-mock-type"]; + } + } +}; + +interface ICreateEventHandler { + req: IncomingMessage; + res: ServerResponse; + mockEvent: LambdaEndpoint; + multiValueHeaders: any; + lambdaName: string; + isBase64Encoded: boolean; + body: any; + parsedURL: URL; + requestId: string; +} + +const createRequestHandler = (params: ICreateEventHandler) => { + return params.mockEvent.kind == "alb" ? new AlbRequestHandler(params) : new ApgRequestHandler(params); +}; + +export const defaultServer = async (req: IncomingMessage, res: ServerResponse, parsedURL: URL) => { + const { url, method, headers, rawHeaders } = req; + const { searchParams } = parsedURL; + + const requestMockType = getRequestMockType(searchParams, headers); + + const multiValueHeaders = CommonEventGenerator.getMultiValueHeaders(rawHeaders); + const normalizedQuery = CommonEventGenerator.normalizeSearchParams(searchParams, decodeURI(url!)); + const lambdaController = Handlers.findHandler({ + headers: multiValueHeaders, + query: normalizedQuery, + method: method as HttpMethod, + path: decodeURIComponent(parsedURL.pathname), + kind: requestMockType, + }); + + if (!lambdaController) { + return; + } + + const { event: mockEvent, handler } = lambdaController; + + try { + checkHeaders(headers, mockEvent.kind); + } catch (error: any) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/html"); + return res.end(error.message); + } + if (mockEvent.async) { + res.statusCode = 200; + res.end(); + } + const requestId = randomUUID(); + const isBase64Encoded = CommonEventGenerator.getIsBase64Encoded(headers); + const body = await collectBody(req, isBase64Encoded); + + const requestHandler = createRequestHandler({ req, res, body, isBase64Encoded, multiValueHeaders, mockEvent, lambdaName: handler.outName, parsedURL, requestId }); + + try { + let handlerOutput = await handler.invoke(requestHandler.payload, mockEvent); + + if (handlerOutput instanceof BufferedStreamResponse) { + handlerOutput = handlerOutput.getParsedResponse(); + } + requestHandler.sendResponse(handlerOutput); + } catch (error) { + return requestHandler.returnError(); + } + + return true; +}; diff --git a/src/plugins/lambda/events/alb.ts b/src/plugins/lambda/events/alb.ts new file mode 100644 index 0000000..db6afb2 --- /dev/null +++ b/src/plugins/lambda/events/alb.ts @@ -0,0 +1,251 @@ +import { CommonEventGenerator } from "./common"; +import { IncomingMessage, ServerResponse } from "http"; +import type { LambdaEndpoint } from "../../../lib/parseEvents/endpoints"; +import { log } from "../../../lib/utils/colorize"; +import { html502 } from "../htmlStatusMsg"; + +interface AlbPayload { + requestContext: { + elb: { + targetGroupArn: string; + }; + }; + multiValueHeaders?: { + [key: string]: string[]; + }; + + multiValueQueryStringParameters?: { + [key: string]: string[]; + }; + queryStringParameters?: { [key: string]: string }; + headers?: { [key: string]: any }; + httpMethod: string; + path: string; + isBase64Encoded: boolean; + body?: string; +} + +export class AlbRequestHandler extends CommonEventGenerator { + res: ServerResponse; + payload: AlbPayload; + mockEvent: LambdaEndpoint; + constructor({ + res, + req, + body, + mockEvent, + multiValueHeaders, + isBase64Encoded, + lambdaName, + }: { + res: ServerResponse; + req: IncomingMessage; + body: any; + mockEvent: LambdaEndpoint; + multiValueHeaders: any; + isBase64Encoded: boolean; + lambdaName: string; + }) { + super(); + this.res = res; + this.mockEvent = mockEvent; + this.payload = AlbRequestHandler.#generatePayload({ req, mockEvent, multiValueHeaders, isBase64Encoded, body, lambdaName }); + } + static #execError = new Error(); + returnError = () => { + if (this.res.writableFinished) { + return true; + } + + this.res.shouldKeepAlive = false; + this.res.writeHead(502, [ + ["Server", "awselb/2.0"], + ["Date", new Date().toUTCString()], + ["Content-Type", "text/html"], + ["Content-Length", "127"], + ["Connection", "keep-alive"], + ]); + + return this.res.end(html502); + }; + sendResponse = (output?: any) => { + const headers = [ + ["Server", "awselb/2.0"], + ["Date", new Date().toUTCString()], + ]; + const customHeaders: [string, string][] = []; + let code = 200; + let statusMessage = undefined; + + if (!output || typeof output.statusCode !== "number") { + log.RED("Valid ALB Lambda response must be an object which includes a valid 'statusCode' number"); + + throw AlbRequestHandler.#execError; + } + + code = output.statusCode; + + const { statusDescription } = output; + if (typeof statusDescription == "string") { + const descComponents = statusDescription.split(" "); + if (isNaN(descComponents[0] as unknown as number)) { + log.RED("statusDescription must start with a statusCode number followed by a space + status description text"); + log.YELLOW("example: '200 Found'"); + } else { + const desc = descComponents.slice(1).join(" "); + if (desc.length) { + statusMessage = desc; + } + } + } + + if (this.mockEvent.multiValueHeaders) { + if (output.multiValueHeaders) { + const headersKeys = Object.keys(output.multiValueHeaders).filter((key) => key !== "Server" && key !== "Date"); + headersKeys.forEach((key) => { + if (Array.isArray(output.multiValueHeaders[key])) { + customHeaders.push([key, output.multiValueHeaders[key]]); + } else { + log.RED(`multiValueHeaders (${key}) values must be an array`); + log.YELLOW("example:"); + log.GREEN(`'${key}': ['some/value']`); + throw AlbRequestHandler.#execError; + } + }); + } else if (output.headers) { + log.YELLOW("An ALB Lambda with 'multiValueHeaders enabled' must return 'multiValueHeaders' instead of 'headers'"); + } + } else { + if (typeof output.headers == "object" && !Array.isArray(output.headers)) { + const headersKeys = Object.keys(output.headers).filter((key) => key !== "Server" && key !== "Date"); + + headersKeys.forEach((key) => { + const valueType = typeof output.headers[key]; + if (valueType == "string") { + customHeaders.push([key, output.headers[key]]); + } else { + log.RED(`response headers (${key}) value must be typeof string.\nReceived: '${valueType}'`); + throw new Error(); + } + }); + } else if (output.multiValueHeaders) { + log.YELLOW("Skipping 'multiValueHeaders' as it is not enabled for you target group in serverless.yml"); + } + } + + const bodyType = typeof output.body; + if (bodyType != "undefined" && bodyType != "string") { + log.RED(`response 'body' must be a string. Receievd ${bodyType}`); + throw AlbRequestHandler.#execError; + } + + let resContent = output.body; + + if (resContent && output.isBase64Encoded) { + const tmpContent = Buffer.from(resContent, "base64").toString(); + const reDecoded = Buffer.from(tmpContent).toString("base64"); + + if (reDecoded != resContent) { + log.RED("response body is not properly base64 encoded"); + throw AlbRequestHandler.#execError; + } else { + resContent = tmpContent; + } + } + const contentTypeIndex = customHeaders.findIndex((x) => x[0].toLowerCase() == "content-type"); + if (contentTypeIndex == -1) { + headers.push(["Content-Type", "application/octet-stream"]); + } else { + headers.push(["Content-Type", customHeaders[contentTypeIndex][1]]); + customHeaders.splice(contentTypeIndex, 1); + } + + const contentLengthIndex = customHeaders.findIndex((x) => x[0].toLowerCase() == "content-length"); + const contentLengh = resContent ? String(Buffer.from(resContent).byteLength) : "0"; + if (contentLengthIndex != -1) { + customHeaders.splice(contentLengthIndex, 1); + } + headers.push(["Content-Length", contentLengh], ["Connection", "keep-alive"], ...customHeaders); + this.res.shouldKeepAlive = false; + if (statusMessage) { + this.res.writeHead(code, statusMessage, headers); + } else { + this.res.writeHead(code, headers); + } + + this.res.end(resContent); + }; + + static #generatePayload({ + req, + mockEvent, + multiValueHeaders, + isBase64Encoded, + body, + lambdaName, + }: { + req: IncomingMessage; + mockEvent: LambdaEndpoint; + multiValueHeaders: { [key: string]: string[] }; + isBase64Encoded: boolean; + body: any; + lambdaName: string; + }) { + const { method, headers, url } = req; + + const parsedURL = new URL(url as string, "http://localhost:3003"); + parsedURL.searchParams.delete("x_mock_type"); + + let event: AlbPayload = { + requestContext: { + elb: { + targetGroupArn: `arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/${lambdaName}-tg/49e9d65c45c6791a`, + }, + }, + httpMethod: method as string, + path: parsedURL.pathname, + isBase64Encoded: body ? isBase64Encoded : false, + body: body ?? "", + }; + + if (mockEvent.multiValueHeaders) { + event.multiValueHeaders = { + "x-forwarded-for": [String(req.socket.remoteAddress)], + "x-forwarded-proto": ["http"], + "x-forwarded-port": [String(CommonEventGenerator.port)], + ...multiValueHeaders, + }; + + event.multiValueQueryStringParameters = CommonEventGenerator.getMultiValueQueryStringParameters(parsedURL.searchParams); + } else { + event.headers = { + "x-forwarded-for": req.socket.remoteAddress, + "x-forwarded-proto": "http", + "x-forwarded-port": CommonEventGenerator.port, + ...headers, + }; + event.queryStringParameters = AlbRequestHandler.#paramsToAlbObject(url as string); + + if (event.headers["x-mock-type"]) { + delete event.headers["x-mock-type"]; + } + } + return event; + } + static #paramsToAlbObject(reqUrl: string) { + const queryStartIndex = reqUrl.indexOf("?"); + if (queryStartIndex == -1) return {}; + + let queryStringComponents: any = {}; + const queryString = reqUrl.slice(queryStartIndex + 1); + const queryComponents = queryString.split("&"); + + queryComponents.forEach((c) => { + const [key, value] = c.split("="); + queryStringComponents[key] = value; + }); + + delete queryStringComponents.x_mock_type; + return queryStringComponents; + } +} diff --git a/src/plugins/lambda/events/apg.ts b/src/plugins/lambda/events/apg.ts new file mode 100644 index 0000000..95058f8 --- /dev/null +++ b/src/plugins/lambda/events/apg.ts @@ -0,0 +1,469 @@ +import type { IncomingMessage, ServerResponse } from "http"; +import type { LambdaEndpoint } from "../../../lib/parseEvents/endpoints"; +import { CommonEventGenerator } from "./common"; +import { log } from "../../../lib/utils/colorize"; +import { randomUUID } from "crypto"; +import { BufferedStreamResponse } from "../../../lib/runtime/bufferedStreamResponse"; +import { capitalize } from "../utils"; + +interface CommonApgEvent { + body?: string; + queryStringParameters: { [key: string]: string }; + isBase64Encoded: boolean; + headers: { [key: string]: any }; + pathParameters?: { [key: string]: any }; +} + +export type ApgHttpApiEvent = { + version: string; + routeKey: string; + rawPath: string; + rawQueryString: string; + cookies?: string[]; + requestContext: { + accountId: string; + apiId: string; + domainName: string; + domainPrefix: string; + http: { + method: string; + path: string; + protocol: string; + sourceIp: string; + userAgent: string; + }; + requestId: string; + routeKey: string; + stage: string; + time: string; + timeEpoch: number; + }; +} & CommonApgEvent; + +export type ApgHttpEvent = { + version?: string; + resource: string; + path: string; + httpMethod: string; + multiValueHeaders: { [key: string]: any }; + multiValueQueryStringParameters: { [key: string]: any }; + requestContext: { + accountId: string; + apiId: string; + domainName: string; + domainPrefix: string; + extendedRequestId: string; + httpMethod: string; + path: string; + protocol: string; + requestId: string; + requestTime: string; + requestTimeEpoch: number; + resourcePath: string; + stage: string; + }; +} & CommonApgEvent; + +export class ApgRequestHandler extends CommonEventGenerator { + res: ServerResponse; + mockEvent: LambdaEndpoint; + payload: ApgHttpApiEvent | ApgHttpEvent; + constructor({ + res, + req, + body, + mockEvent, + multiValueHeaders, + isBase64Encoded, + lambdaName, + parsedURL, + requestId, + }: { + res: ServerResponse; + req: IncomingMessage; + body: any; + mockEvent: LambdaEndpoint; + multiValueHeaders: any; + isBase64Encoded: boolean; + lambdaName: string; + parsedURL: URL; + requestId: string; + }) { + super(); + this.res = res; + this.mockEvent = mockEvent; + this.payload = + mockEvent.version == 1 + ? ApgRequestHandler.createApgV1Event({ req, mockEvent, parsedURL, lambdaName, multiValueHeaders, isBase64Encoded, requestId, body }) + : ApgRequestHandler.createApgV2Event({ req, mockEvent, parsedURL, requestId, isBase64Encoded, body }); + } + + static knownHeaders: string[] = ["content-length", "host", "user-agent"]; + static skipHeaders: string[] = ["connection", "content"]; + static createApgV2Event = ({ + req, + mockEvent, + parsedURL, + requestId, + isBase64Encoded, + body, + }: { + req: IncomingMessage; + mockEvent: LambdaEndpoint; + isBase64Encoded: boolean; + parsedURL: URL; + body: any; + requestId: string; + }): ApgHttpApiEvent => { + const { method, headers } = req; + const pathParameters: any = ApgRequestHandler.getPathParameters(mockEvent, parsedURL); + + let event: any; + const customMethod = mockEvent.methods.find((x) => x == method) ?? "ANY"; + + let queryStringParameters: any = {}; + let rawQueryString = ""; + for (const k of Array.from(new Set(parsedURL.searchParams.keys()))) { + const values = parsedURL.searchParams.getAll(k); + + rawQueryString += `&${values.map((x) => encodeURI(`${k}=${x}`)).join("&")}`; + queryStringParameters[k] = values.join(","); + } + if (rawQueryString) { + rawQueryString = rawQueryString.slice(1); + } + + const routeKey = mockEvent.paths[0] == "/*" ? "$default" : `${customMethod} ${parsedURL.pathname}`; + const date = new Date(); + const defaultIp = "127.0.0.1"; + const sourceIp = headers.host?.startsWith("localhost") ? defaultIp : headers.host ? headers.host.split(":")[0] : defaultIp; + const apgEvent: Partial = { + version: "2.0", + routeKey, + rawPath: parsedURL.pathname, + rawQueryString, + headers: ApgRequestHandler.getCustomHeaders(req, mockEvent), + + requestContext: { + accountId: String(ApgRequestHandler.accountId), + apiId: ApgRequestHandler.apiId, + domainName: `localhost:${ApgRequestHandler.port}`, + domainPrefix: "localhost", + http: { + method: method as string, + path: parsedURL.pathname, + protocol: "HTTP/1.1", + sourceIp, + userAgent: headers["user-agent"] ?? "", + }, + requestId, + routeKey, + stage: "$default", + time: date.toISOString(), + timeEpoch: date.getTime(), + }, + }; + if (body) { + apgEvent.body = body; + apgEvent.isBase64Encoded = isBase64Encoded; + } else { + apgEvent.isBase64Encoded = false; + } + + if (Object.keys(queryStringParameters).length) { + apgEvent.queryStringParameters = queryStringParameters; + } + + if (Object.keys(pathParameters).length) { + apgEvent.pathParameters = pathParameters; + } + if (headers.cookie) { + apgEvent.cookies = headers.cookie.split("; "); + } + + event = apgEvent; + + if (event.headers["x-mock-type"]) { + delete event.headers["x-mock-type"]; + } + return event; + }; + + static createApgV1Event = ({ + req, + mockEvent, + parsedURL, + lambdaName, + multiValueHeaders, + isBase64Encoded, + requestId, + body, + }: { + req: IncomingMessage; + mockEvent: LambdaEndpoint; + parsedURL: URL; + lambdaName: string; + isBase64Encoded: boolean; + multiValueHeaders: { [key: string]: string[] }; + requestId: string; + body: any; + }): ApgHttpEvent => { + const { method } = req; + + let event: any = {}; + if (mockEvent.proxy == "httpApi") { + event.version = "1.0"; + } + const resourcePath = mockEvent.paths[0]; + event.resource = resourcePath; + event.path = parsedURL.pathname; + event.httpMethod = method!; + + const headers = ApgRequestHandler.getCustomHeaders(req, mockEvent); + + event.headers = headers; + + const sourceIp = String(req.socket.remoteAddress); + const mergedMultiValueHeaders: any = { + "X-Forwarded-For": [sourceIp], + "X-Forwarded-Proto": ["http"], + "X-Forwarded-Port": [String(ApgRequestHandler.port)], + }; + Object.entries(multiValueHeaders).forEach(([key, value]) => { + mergedMultiValueHeaders[this.#normalizeHeaderKey(key)] = value; + }); + + event.multiValueHeaders = mergedMultiValueHeaders; + + const queryStringParameters: any = Object.fromEntries(parsedURL.searchParams); + event.queryStringParameters = Object.keys(queryStringParameters).length ? queryStringParameters : null; + + const multiValueQueryStringParameters: any = ApgRequestHandler.getMultiValueQueryStringParameters(parsedURL.searchParams); + event.multiValueQueryStringParameters = Object.keys(multiValueQueryStringParameters).length ? multiValueQueryStringParameters : null; + const date = new Date(); + const requestContext: any = { + accountId: String(ApgRequestHandler.accountId), + apiId: ApgRequestHandler.apiId, + domainName: `localhost:${ApgRequestHandler.port}`, + domainPrefix: "localhost", + extendedRequestId: "fake-id", + httpMethod: method!, + identity: { + accessKey: null, + accountId: null, + caller: null, + cognitoAuthenticationProvider: null, + cognitoAuthenticationType: null, + cognitoIdentityId: null, + cognitoIdentityPoolId: null, + principalOrgId: null, + sourceIp: sourceIp, + user: null, + userAgent: req.headers["user-agent"], + userArn: null, + }, + path: parsedURL.pathname, + protocol: "HTTP/1.1", + requestId, + requestTime: date.toISOString(), + requestTimeEpoch: date.getTime(), + resourceId: "exhmtv", + resourcePath, + }; + if (mockEvent.proxy == "httpApi") { + requestContext.identity.cognitoAmr = null; + requestContext.stage = "$default"; + } else { + requestContext.stage = "dev"; + } + event.requestContext = requestContext; + + const pathParameters: any = ApgRequestHandler.getPathParameters(mockEvent, parsedURL); + event.pathParameters = Object.keys(pathParameters).length ? pathParameters : null; + event.stageVariables = null; + + event.body = body ?? null; + event.isBase64Encoded = body ? isBase64Encoded : false; + + return event; + }; + returnError = () => { + if (this.res.writableFinished) { + return true; + } + this.res.shouldKeepAlive = false; + this.res.writeHead(500, [ + ["Date", new Date().toUTCString()], + ["Content-Type", "application/json"], + ["Content-Length", "35"], + ["Connection", "keep-alive"], + ["Apigw-Requestid", "ETuSEj-PiGYEJdQ="], + ]); + + return this.res.end(BufferedStreamResponse.httpErrMsg); + }; + + static #normalizeHeaderKey = (key: string) => { + return key.toLowerCase().split("-").map(capitalize).join("-"); + }; + #normalizeV1Value = (v: any) => { + let value: string | null = ""; + const vType = typeof v; + + if (vType == "string" || vType == "number" || vType == "boolean") { + value = String(v); + } else if (v === null) { + value = ""; + } else { + throw new Error(""); + } + + return value; + }; + sendV1Response = (output?: any) => { + const headers = [ + ["Date", new Date().toUTCString()], + ["Apigw-Requestid", Buffer.from(randomUUID()).toString("base64").slice(0, 16)], + ["Connection", "keep-alive"], + ]; + let code = 200; + + if (!output || isNaN(output.statusCode)) { + log.RED("Valid 'http' response payload must be an object which includes a valid 'statusCode'"); + + throw new Error(); + } + code = output.statusCode; + + if (output.headers && typeof output.headers == "object" && !Array.isArray(output.headers)) { + Object.entries(output.headers).forEach(([k, v]) => { + const key = ApgRequestHandler.#normalizeHeaderKey(k); + const value = this.#normalizeV1Value(v); + + headers.push([key, value]); + }); + } + + if (output.multiValueHeaders && typeof output.multiValueHeaders == "object" && !Array.isArray(output.multiValueHeaders)) { + Object.entries(output.multiValueHeaders).forEach(([k, v]) => { + const key = ApgRequestHandler.#normalizeHeaderKey(k); + + if (!Array.isArray(v)) { + throw new Error(); + } + const values = v.map((x) => this.#normalizeV1Value(x)); + + values.forEach((nw) => { + headers.push([key, nw]); + }); + }); + } + + let resContent = output.body; + + if (output.isBase64Encoded && resContent) { + if (typeof resContent != "string") { + throw new Error(); + } + const tmpContent = Buffer.from(resContent, "base64").toString(); + const reDecoded = Buffer.from(tmpContent).toString("base64"); + + if (reDecoded != resContent) { + log.RED("response body is not properly base64 encoded"); + throw new Error(); + } else { + resContent = tmpContent; + } + } + + const contentTypeIndex = headers.findIndex((x) => x[0].toLowerCase() == "content-type"); + + if (contentTypeIndex == -1 && resContent) { + headers.push(["Content-Type", "text/plain; charset=utf-8"]); + } + + const contentLengthIndex = headers.findIndex((x) => x[0].toLowerCase() == "content-length"); + if (contentLengthIndex != -1) { + headers.splice(contentLengthIndex, 1); + } + + const contentType = typeof resContent; + if (contentType == "number" || contentType == "boolean") { + resContent = String(resContent); // maybe use value normalizer + } else { + // TODO: check type + } + + const contentLengh = resContent ? String(Buffer.from(resContent).byteLength) : "0"; + headers.push(["Content-Length", contentLengh]); + + this.res.shouldKeepAlive = false; + this.res.writeHead(code, headers); + this.res.end(resContent); + }; + sendV2Response = (output?: any) => { + this.res.setHeader("Apigw-Requestid", Buffer.from(randomUUID()).toString("base64").slice(0, 16)); + this.res.setHeader("Date", new Date().toUTCString()); + + if (output) { + if (typeof output.headers == "object" && !Array.isArray(output.headers)) { + const headersKeys = Object.keys(output.headers).filter((key) => key !== "Apigw-Requestid" && key !== "Date"); + headersKeys.forEach((key) => { + this.res.setHeader(key, output.headers[key]); + }); + } + + if (!output.statusCode) { + this.res.statusCode = 200; + } else { + this.res.statusCode = output.statusCode; + + if (output.cookies?.length) { + this.res.setHeader("Set-Cookie", output.cookies); + // log.RED(`'cookies' as return value is supported only in API Gateway HTTP API (httpApi) payload v2.\nUse 'Set-Cookie' header instead`); + } + } + } else { + this.res.statusCode = 200; + } + + let resContent = ""; + if (!output) { + return this.res.end(); + } + + if (typeof output == "object" && typeof output.body == "string") { + resContent = output.body; + + if (output.isBase64Encoded) { + const tmpContent = Buffer.from(resContent, "base64").toString(); + const reDecoded = Buffer.from(tmpContent).toString("base64"); + + if (reDecoded != resContent) { + log.RED("response body is not properly base64 encoded"); + throw new Error(); + } else { + resContent = tmpContent; + } + } + } + if (typeof output == "string") { + this.res.setHeader("Content-Type", "application/json"); + resContent = output; + } else if (typeof output == "object" && !output.statusCode) { + this.res.setHeader("Content-Type", "application/json"); + resContent = JSON.stringify(output); + } + + this.res.end(resContent); + }; + sendResponse = (output?: any) => { + if (this.res.writableFinished) { + return; + } + if (this.mockEvent.version == 1) { + return this.sendV1Response(output); + } + return this.sendV2Response(output); + }; +} diff --git a/src/plugins/lambda/events/common.ts b/src/plugins/lambda/events/common.ts new file mode 100644 index 0000000..3212716 --- /dev/null +++ b/src/plugins/lambda/events/common.ts @@ -0,0 +1,108 @@ +import { IncomingMessage, IncomingHttpHeaders } from "http"; +import type { LambdaEndpoint } from "../../../lib/parseEvents/endpoints"; + +export type normalizedSearchParams = { toString: () => string } & { [key: string]: string[] | undefined }; + +export class CommonEventGenerator { + static apiId: string = ""; + static accountId: string = ""; + static port: number = 3000; + static serve: any; + static getMultiValueHeaders = (rawHeaders: string[]) => { + let multiValueHeaders: any = {}; + const multiKeys = rawHeaders.filter((x, i) => i % 2 == 0).map((x) => x.toLowerCase()); + const multiValues = rawHeaders.filter((x, i) => i % 2 !== 0); + + multiKeys.forEach((x, i) => { + if (x == "x-mock-type") { + return; + } + if (multiValueHeaders[x]) { + multiValueHeaders[x].push(multiValues[i]); + } else { + multiValueHeaders[x] = [multiValues[i]]; + } + }); + + return multiValueHeaders; + }; + + static getIsBase64Encoded = (headers: IncomingHttpHeaders) => { + const contentType = headers["content-type"]; + if (headers["content-encoding"] || !contentType) { + return true; + } + + if ( + contentType.startsWith("application/json") || + contentType.startsWith("application/xml") || + contentType.startsWith("application/javascript") || + contentType.startsWith("text/") + ) { + return false; + } + + return true; + }; + + static getCustomHeaders(req: IncomingMessage, mockEvent: LambdaEndpoint) { + const { headers } = req; + delete headers["x-mock-type"]; + delete headers.connection; + + return { "X-Forwarded-For": req.socket.remoteAddress, "X-Forwarded-Proto": "http", "X-Forwarded-Port": CommonEventGenerator.port, ...headers }; + } + static getPathParameters(mockEvent: LambdaEndpoint, parsedURL: URL) { + const paramDeclarations = mockEvent.paths[0].split("/"); + const reqParams = parsedURL.pathname.split("/"); + let pathParameters: any = {}; + + paramDeclarations.forEach((k, i) => { + if (k.startsWith("{") && k.endsWith("}") && !k.endsWith("+}")) { + pathParameters[k.slice(1, -1)] = reqParams[i]; + } + }); + return pathParameters; + } + static normalizeSearchParams = (searchParams: URLSearchParams, rawUrl: string) => { + let query: normalizedSearchParams = {}; + + Array.from(searchParams.keys()).forEach((x) => { + const values = searchParams.getAll(x).map((v) => v.toLowerCase()); + const key = x.toLowerCase(); + + if (values) { + if (query[key]) { + query[key]!.push(...values); + } else { + query[key] = values; + } + } else if (!query[key]) { + query[key] = undefined; + } + }); + + let rawSearchParams = ""; + + // url may include multiple '?' so we avoid .split() + const foundIndex = rawUrl.indexOf("?"); + if (foundIndex != -1) { + rawSearchParams = rawUrl.slice(foundIndex + 1); + } + + query.toString = () => rawSearchParams; + + return query; + }; + + static getMultiValueQueryStringParameters = (searchParams: URLSearchParams) => { + let multiValueQueryStringParameters: any = {}; + + searchParams.delete("x_mock_type"); + + for (const k of Array.from(new Set(searchParams.keys()))) { + multiValueQueryStringParameters[k] = searchParams.getAll(k).map(encodeURI); + } + return multiValueQueryStringParameters; + }; +} diff --git a/src/plugins/lambda/functionUrlInvoke.ts b/src/plugins/lambda/functionUrlInvoke.ts new file mode 100644 index 0000000..f8a2926 --- /dev/null +++ b/src/plugins/lambda/functionUrlInvoke.ts @@ -0,0 +1,202 @@ +import type { OfflineRequest } from "../../defineConfig"; +import type { ServerResponse } from "http"; +import { ApgRequestHandler } from "./events/apg"; +import { amazonifyHeaders } from "../../lib/utils/amazonifyHeaders"; +import { chunkToJs, setRequestId, isDelimiter, collectBody, internalServerError, isStreamResponse, unsupportedMethod } from "./utils"; +import { BufferedStreamResponse } from "../../lib/runtime/bufferedStreamResponse"; +import { Handlers } from "../../lib/server/handlers"; +import { CommonEventGenerator } from "./events/common"; + +const createStreamResponseHandler = (res: ServerResponse, foundHandler: any) => { + const serverRes = foundHandler.url.stream ? res : undefined; + + if (serverRes) { + let isHttpIntegrationResponse = false; + const originalWrite = res.write.bind(res); + const originalSetHeader = res.setHeader.bind(res); + let contentType: any; + let collectedChunks: any = undefined; + + const collectChunks = (chunk: Uint8Array) => { + collectedChunks = collectedChunks ? Buffer.concat([collectedChunks, chunk]) : chunk; + }; + + const sendHeaders = () => { + if (collectedChunks) { + let data = chunkToJs(collectedChunks); + + const code = data.statusCode ?? 200; + try { + const headers = amazonifyHeaders(data.headers, data.cookies); + res.writeHead(code, headers); + } catch (error: any) { + res.writeHead(500, error.headers); + res.end?.(BufferedStreamResponse.amzMsgNull); + delete error.headers; + } + } else { + res.writeHead(200); + } + }; + serverRes.setHeader = (name: string, value: number | string | ReadonlyArray) => { + if (name.toLowerCase() == "content-type") { + if (value == "application/vnd.awslambda.http-integration-response") { + isHttpIntegrationResponse = true; + return originalSetHeader("Content-Type", "application/json"); + } else { + contentType = value; + } + } + + return originalSetHeader(name, value); + }; + + let sendHeadersBefore = false; + // @ts-ignore + serverRes.write = (chunk: any, encoding: BufferEncoding, cb?: (error: Error | null | undefined) => void) => { + if (serverRes.headersSent) { + return originalWrite(chunk, encoding, cb); + } else if (sendHeadersBefore) { + sendHeaders(); + return originalWrite(chunk, encoding, cb); + } else { + // first bytes to be written to body + if (isHttpIntegrationResponse) { + if (isDelimiter(chunk)) { + sendHeadersBefore = true; + } else { + collectChunks(chunk); + } + } else { + if (contentType == "application/json") { + // AMZ checks if the first write() is parsable + try { + const chunkString = BufferedStreamResponse.codec.decode(chunk); + const parsed = JSON.parse(chunkString); + if (parsed === null || typeof parsed == "number") { + throw new Error(chunkString); + } + } catch (error: any) { + const err = new Error( + `When 'Content-Type' is 'application/json' first chunk of .write() must be parsable JSON and not be null or number.\n${foundHandler.name} => .write(${error.message})` + ); + err.cause = error.message; + console.error(err); + return internalServerError(res); + } + } + const headers = { "Content-Type": contentType ?? "application/octet-stream" }; + serverRes.writeHead(200, headers); + return originalWrite(chunk, encoding, cb); + } + } + }; + + return serverRes; + } +}; + +const handleInvokeResponse = (result: any, res: ServerResponse, invokeResponseStream: any) => { + const streamifyResponse = isStreamResponse(result); + let response = streamifyResponse ? result.getParsedResponse() : result; + + let finalResponse; + let toBase64 = false; + if (response && typeof response == "object" && response.statusCode) { + const invalidCode = isNaN(response.statusCode); + const code = invalidCode ? 500 : response.statusCode; + toBase64 = !invalidCode && (response.isBase64Encoded == true || response.isBase64Encoded == "true"); + + if (response.headers || response.cookies) { + try { + const headers = amazonifyHeaders(response.headers, response.cookies); + + res.writeHead(code, headers); + } catch (error: any) { + res.writeHead(500, error.headers); + delete error.headers; + console.error(error); + return res.end(BufferedStreamResponse.amzMsgNull); + } + } else { + res.writeHead(code, { "Content-Type": "application/json" }); + } + + if (!invokeResponseStream) { + if (typeof response.body != "undefined") { + finalResponse = response.body; + } + if (!res.headersSent) { + res.setHeader("Content-Type", streamifyResponse ? "application/octet-stream" : "application/json"); + } + } else { + finalResponse = response; + } + } else { + res.writeHead(200, { "Content-Type": "application/json" }); + finalResponse = response; + } + + if (!res.headersSent && !invokeResponseStream && streamifyResponse) { + res.setHeader("Content-Type", "application/octet-stream"); + } + + if (invokeResponseStream) { + if (finalResponse instanceof Uint8Array) { + res.end(finalResponse); + } else { + res.end(JSON.stringify(finalResponse)); + } + } else { + if (typeof finalResponse == "number" || typeof finalResponse == "boolean" || finalResponse == null || finalResponse instanceof Object) { + finalResponse = JSON.stringify(finalResponse); + } + + if (finalResponse && toBase64) { + finalResponse = Buffer.from(finalResponse, "base64"); + } + res.end(finalResponse); + } +}; + +export const functionUrlInvoke: OfflineRequest = { + filter: /^\/@url\//, + callback: async function (req, res) { + const { url, method, headers } = req; + if (unsupportedMethod(res, method!)) { + return; + } + const parsedURL = new URL(url as string, "http://localhost:3003"); + + const requestId = setRequestId(res); + const requestedName = Handlers.parseNameFromUrl(parsedURL.pathname); + const foundHandler = Handlers.handlers.find((x) => x.name == requestedName || x.outName == requestedName); + + if (!foundHandler?.url) { + res.statusCode = 404; + return res.end("Not Found"); + } + const isBase64Encoded = CommonEventGenerator.getIsBase64Encoded(headers); + + const body = await collectBody(req, isBase64Encoded); + const reqEvent = ApgRequestHandler.createApgV2Event({ req, mockEvent: foundHandler.url, parsedURL, requestId, body, isBase64Encoded }); + const fixedPath = parsedURL.pathname.replace(`/@url/${foundHandler.name}/`, "/").replace(`/@url/${foundHandler.outName}/`, "/"); + reqEvent.requestContext.accountId = "anonymous"; + reqEvent.rawPath = fixedPath; + reqEvent.requestContext.http.path = fixedPath; + + try { + const serverRes = createStreamResponseHandler(res, foundHandler); + //@ts-ignore + const result = await foundHandler.invoke(reqEvent, foundHandler.url, undefined, serverRes); + + if (!result) { + return res.end(); + } + handleInvokeResponse(result, res, serverRes); + } catch (error) { + console.error(error); + internalServerError(res); + } + }, +}; diff --git a/src/lib/utils/htmlStatusMsg.ts b/src/plugins/lambda/htmlStatusMsg.ts similarity index 95% rename from src/lib/utils/htmlStatusMsg.ts rename to src/plugins/lambda/htmlStatusMsg.ts index 8f745c6..62b2d9b 100644 --- a/src/lib/utils/htmlStatusMsg.ts +++ b/src/plugins/lambda/htmlStatusMsg.ts @@ -1,4 +1,4 @@ -export const html500 = ` +export const html502 = ` 502 Bad Gateway diff --git a/src/plugins/lambda/index.ts b/src/plugins/lambda/index.ts index 1777d45..1882405 100644 --- a/src/plugins/lambda/index.ts +++ b/src/plugins/lambda/index.ts @@ -1,143 +1,7 @@ -import type { IncomingMessage, ServerResponse } from "http"; -import { randomUUID } from "crypto"; -import { log } from "../../lib/utils/colorize"; -import { Handlers } from "../../lib/server/handlers"; +import type { OfflineRequest } from "../../defineConfig"; -enum InvokationType { - DryRun = 204, - Event = 202, - RequestResponse = 200, -} -enum errprType { - invalidRequest = "InvalidRequestContentException", - notFound = "ResourceNotFoundException", -} -const parseClientContext = (contextAsBase64?: string) => { - if (!contextAsBase64) { - return; - } +import { invokeRequests } from "./invokeRequests"; +import { functionUrlInvoke } from "./functionUrlInvoke"; +import { responseStreamingInvoke } from "./responseStreamingInvoke"; - try { - const decoded = Buffer.from(contextAsBase64 as string, "base64").toString("utf-8"); - const parsed = JSON.parse(decoded); - return parsed; - } catch (error) { - return new Error(errprType.invalidRequest); - } -}; - -const base64ErorMsg = JSON.stringify({ Type: null, message: "Client context must be a valid Base64-encoded JSON object." }); -const invalidPayloadErrorMsg = (msg: string) => { - return JSON.stringify({ - Type: "User", - message: `Could not parse request body into json: Could not parse payload into json: ${msg}: was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')`, - }); -}; - -const notFound = (lambdaName: string) => { - return JSON.stringify({ - Type: "User", - message: `Function not found: arn:aws:lambda:region:000000000000:function:${lambdaName}`, - }); -}; - -export const invokeRequests = { - filter: /(^\/2015-03-31\/functions\/)|(^\/@invoke\/)/, - callback: async function (req: IncomingMessage, res: ServerResponse) { - const { url, method, headers } = req; - const parsedURL = new URL(url as string, "http://localhost:3003"); - - const requestedName = Handlers.parseNameFromUrl(parsedURL.pathname); - const foundHandler = Handlers.handlers.find((x) => x.name == requestedName || x.outName == requestedName); - const awsRequestId = randomUUID(); - res.setHeader("Content-Type", "application/json"); - res.setHeader("x-amzn-RequestId", awsRequestId); - - req.on("error", (err) => { - res.statusCode = 502; - res.end(JSON.stringify(err)); - }); - - if (!foundHandler) { - res.statusCode = 404; - res.setHeader("x-amzn-errortype", errprType.notFound); - return res.end(notFound(requestedName)); - } - - const invokeType = headers["x-amz-invocation-type"]; - const clientContext = parseClientContext(headers["x-amz-client-context"] as string); - - if (clientContext instanceof Error) { - res.setHeader("x-amzn-errortype", errprType.invalidRequest); - res.statusCode = 400; - return res.end(base64ErorMsg); - } - - const exceptedStatusCode = invokeType == "DryRun" ? InvokationType.DryRun : invokeType == "Event" ? InvokationType.Event : InvokationType.RequestResponse; - let event = ""; - let body: any = Buffer.alloc(0); - - req.on("data", (chunk) => { - body += chunk; - }); - - await new Promise((resolve) => { - req.on("end", async () => { - body = body.toString(); - resolve(undefined); - }); - }); - - let isParsedBody: any = false; - try { - body = JSON.parse(body); - isParsedBody = true; - } catch (error: any) { - if (body && body.length) { - isParsedBody = error; - } else { - body = {}; - isParsedBody = true; - } - } - event = body; - - res.setHeader("X-Amzn-Trace-Id", `root=1-xxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx;sampled=0`); - - if (isParsedBody instanceof Error) { - res.statusCode = 400; - res.setHeader("x-amzn-errortype", errprType.invalidRequest); - - return res.end(invalidPayloadErrorMsg(isParsedBody.message)); - } - - res.setHeader("X-Amz-Executed-Version", "$LATEST"); - - const date = new Date(); - - let info: any = {}; - res.statusCode = exceptedStatusCode; - if (exceptedStatusCode !== 200) { - res.end(); - } - // "Event" invokation type is an async invoke - if (exceptedStatusCode == 202) { - info.kind = "async"; - } - log.CYAN(`${date.toLocaleDateString()} ${date.toLocaleTimeString()} requestId: ${awsRequestId} | '${foundHandler.name}' ${method}`); - - try { - const result = await foundHandler.invoke(event, info, clientContext); - - if (exceptedStatusCode == 200) { - res.end(JSON.stringify(result)); - } - } catch (error: any) { - if (!res.writableFinished) { - res.setHeader("X-Amz-Function-Error", error.errorType); - res.statusCode = 200; - res.end(JSON.stringify(error)); - } - } - }, -}; +export const LambdaRequests: OfflineRequest[] = [invokeRequests, responseStreamingInvoke, functionUrlInvoke]; diff --git a/src/plugins/lambda/invokeRequests.ts b/src/plugins/lambda/invokeRequests.ts new file mode 100644 index 0000000..3aadc06 --- /dev/null +++ b/src/plugins/lambda/invokeRequests.ts @@ -0,0 +1,98 @@ +import type { OfflineRequest } from "../../defineConfig"; +import { errorType, parseBody, parseClientContext, collectBody, InvokationType, isStreamResponse, setRequestId, invalidPayloadErrorMsg, base64ErorMsg, notFound } from "./utils"; +import { Handlers } from "../../lib/server/handlers"; +import { log } from "../../lib/utils/colorize"; +import { BufferedStreamResponse } from "../../lib/runtime/bufferedStreamResponse"; + +export const invokeRequests: OfflineRequest = { + filter: /(^\/2015-03-31\/functions\/)|(^\/@invoke\/)/, + callback: async function (req, res) { + const { url, method, headers } = req; + const parsedURL = new URL(url as string, "http://localhost:3003"); + + const requestedName = Handlers.parseNameFromUrl(parsedURL.pathname); + const foundHandler = Handlers.handlers.find((x) => x.name == requestedName || x.outName == requestedName); + const awsRequestId = setRequestId(res); + res.setHeader("Content-Type", "application/json"); + + req.on("error", (err) => { + res.statusCode = 502; + res.end(JSON.stringify(err)); + }); + + if (!foundHandler) { + res.statusCode = 404; + res.setHeader("x-amzn-errortype", errorType.notFound); + return res.end(notFound(requestedName)); + } + + const invokeType = headers["x-amz-invocation-type"]; + const clientContext = parseClientContext(headers["x-amz-client-context"] as string); + + if (clientContext instanceof Error) { + res.setHeader("x-amzn-errortype", errorType.invalidRequest); + res.statusCode = 400; + return res.end(base64ErorMsg); + } + + const exceptedStatusCode = invokeType == "DryRun" ? InvokationType.DryRun : invokeType == "Event" ? InvokationType.Event : InvokationType.RequestResponse; + + const collectedBody = await collectBody(req); + const body: any = parseBody(collectedBody); + res.setHeader("X-Amzn-Trace-Id", `root=1-xxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx;sampled=0`); + + if (body instanceof Error) { + res.statusCode = 400; + res.setHeader("x-amzn-errortype", errorType.invalidRequest); + + return res.end(invalidPayloadErrorMsg(body.message)); + } + + res.setHeader("X-Amz-Executed-Version", "$LATEST"); + + const date = new Date(); + + let info: any = {}; + res.statusCode = exceptedStatusCode; + if (exceptedStatusCode !== 200) { + res.end(); + } + // "Event" invokation type is an async invoke + if (exceptedStatusCode == 202) { + // required for destinations executions + info.kind = "async"; + } + log.CYAN(`${date.toLocaleDateString()} ${date.toLocaleTimeString()} requestId: ${awsRequestId} | '${foundHandler.name}' ${method}`); + + try { + const result = await foundHandler.invoke(body, info, clientContext); + if (exceptedStatusCode == 200) { + if (result) { + const isStreamRes = isStreamResponse(result); + let response = isStreamRes ? result.buffer : result; + + if (isStreamRes) { + response = BufferedStreamResponse.codec.decode(response); + } else { + try { + JSON.parse(response); + } catch (error) { + response = JSON.stringify(response); + } + } + + res.end(response); + } else { + res.end(); + } + } + } catch (error: any) { + console.error(error); + if (!res.writableFinished) { + res.setHeader("X-Amz-Function-Error", error.errorType ?? error.message ?? ""); + res.statusCode = 200; + res.end(JSON.stringify(error)); + } + } + }, +}; diff --git a/src/plugins/lambda/responseStreamingInvoke.ts b/src/plugins/lambda/responseStreamingInvoke.ts new file mode 100644 index 0000000..7ee6531 --- /dev/null +++ b/src/plugins/lambda/responseStreamingInvoke.ts @@ -0,0 +1,59 @@ +import type { IncomingMessage, ServerResponse } from "http"; +import type { OfflineRequest } from "../../defineConfig"; +import { Handlers } from "../../lib/server/handlers"; +import { StreamEncoder } from "./streamEncoder"; + +import { base64ErorMsg, errorType, parseBody, parseClientContext, collectBody, notFound, invalidPayloadErrorMsg, setRequestId } from "./utils"; + +export const responseStreamingInvoke: OfflineRequest = { + filter: /^\/2021-11-15\/functions\//, + callback: async function (req: IncomingMessage, res: ServerResponse) { + const { url, headers } = req; + const parsedURL = new URL(url as string, "http://localhost:3003"); + + const awsRequestId = setRequestId(res); + const requestedName = Handlers.parseNameFromUrl(parsedURL.pathname); + const foundHandler = Handlers.handlers.find((x) => x.name == requestedName || x.outName == requestedName); + + const collectedBody = await collectBody(req); + const body: any = parseBody(collectedBody); + + res.setHeader("X-Amzn-Trace-Id", `root=1-xxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx;sampled=0`); + + if (body instanceof Error) { + res.statusCode = 400; + res.setHeader("x-amzn-errortype", errorType.invalidRequest); + + return res.end(invalidPayloadErrorMsg(body.message)); + } + + const clientContext = parseClientContext(headers["x-amz-client-context"] as string); + + if (clientContext instanceof Error) { + res.setHeader("x-amzn-errortype", errorType.invalidRequest); + res.statusCode = 400; + return res.end(base64ErorMsg); + } + + const codec = new StreamEncoder(res); + if (foundHandler) { + const info: any = foundHandler.url?.stream ?? {}; + + try { + //@ts-ignore + const response = await foundHandler.invoke(body, info, clientContext, codec); + + if (response) { + codec.endWithJson(response); + } + } catch (error: any) { + if (!res.writableFinished) { + codec.endWithError(error); + } + } + } else { + res.statusCode = 404; + res.end(notFound(requestedName)); + } + }, +}; diff --git a/src/plugins/lambda/streamEncoder.ts b/src/plugins/lambda/streamEncoder.ts new file mode 100644 index 0000000..2542549 --- /dev/null +++ b/src/plugins/lambda/streamEncoder.ts @@ -0,0 +1,94 @@ +import type { ServerResponse } from "http"; +import { EventStreamCodec } from "@aws-sdk/eventstream-codec"; +import type { MessageHeaders } from "@aws-sdk/eventstream-codec"; + +interface ErrorMessage { + errorType: string; + errorMessage: string; + trace: string[]; +} + +const fromUtf8 = (input: string, encoding?: BufferEncoding) => { + const buf = Buffer.from(input, encoding); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength / Uint8Array.BYTES_PER_ELEMENT); +}; +const toUtf8 = (input: Uint8Array) => Buffer.from(input, input.byteOffset, input.byteLength).toString("utf8"); + +const codec = new EventStreamCodec(toUtf8, fromUtf8); + +export class StreamEncoder { + res: ServerResponse; + static complete = Buffer.from([ + 0, 0, 0, 102, 0, 0, 0, 84, 31, 86, 200, 105, 11, 58, 101, 118, 101, 110, 116, 45, 116, 121, 112, 101, 7, 0, 14, 73, 110, 118, 111, 107, 101, 67, 111, 109, 112, 108, 101, 116, + 101, 13, 58, 99, 111, 110, 116, 101, 110, 116, 45, 116, 121, 112, 101, 7, 0, 16, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, 47, 106, 115, 111, 110, 13, 58, 109, 101, + 115, 115, 97, 103, 101, 45, 116, 121, 112, 101, 7, 0, 5, 101, 118, 101, 110, 116, 123, 125, 112, 6, 212, 103, + ]); + constructor(res: ServerResponse) { + this.res = res; + } + + write(chunk: any, encoding: BufferEncoding, cb?: (error: Error | null | undefined) => void) { + this.res.write( + codec.encode({ + headers: this.#getStreamHeaders("PayloadChunk", "application/octet-stream"), + body: chunk, + }), + encoding, + cb + ); + } + end(chunk?: Uint8Array) { + if (chunk) { + this.write(chunk, "utf8", () => { + this.res.end(StreamEncoder.complete); + }); + } else { + this.res.end(StreamEncoder.complete); + } + } + setHeader(key: string, value: string) { + // Currently only Content-Type is supported by AWS + if (key == "Content-Type") { + this.res.setHeader("Content-Type", value); + } + } + + destroy() { + this.res.destroy(); + } + + endWithError(payload: ErrorMessage) { + const errorResponse = { + ErrorCode: payload.errorType, + ErrorDetails: JSON.stringify(payload), + }; + const body = Array.from(Buffer.from(JSON.stringify(errorResponse)).values()); + + this.res.end( + codec.encode({ + headers: this.#getStreamHeaders("InvokeComplete", "application/json"), + body: new Uint8Array(body), + }) + ); + } + endWithJson(payload: any) { + const body = Array.from(Buffer.from(JSON.stringify(payload)).values()); + + this.res.write( + codec.encode({ + headers: this.#getStreamHeaders("PayloadChunk", "application/octet-stream"), + body: new Uint8Array(body), + }), + () => { + this.res.end(StreamEncoder.complete); + } + ); + } + #getStreamHeaders(eventType: string, contentType: string, messageType: string = "event"): MessageHeaders { + return { + ":event-type": { type: "string", value: eventType }, + ":content-type": { type: "string", value: contentType }, + ":message-type": { type: "string", value: messageType }, + }; + } +} diff --git a/src/plugins/lambda/utils.ts b/src/plugins/lambda/utils.ts new file mode 100644 index 0000000..87de778 --- /dev/null +++ b/src/plugins/lambda/utils.ts @@ -0,0 +1,190 @@ +import type { IncomingMessage, ServerResponse } from "http"; +import type { LambdaEndpoint } from "../../lib/parseEvents/endpoints"; +import { BufferedStreamResponse } from "../../lib/runtime/bufferedStreamResponse"; +import { randomUUID } from "crypto"; + +import { headerTooLarge, badRequest } from "./htmlStatusMsg"; + +export enum InvokationType { + DryRun = 204, + Event = 202, + RequestResponse = 200, +} +export enum errorType { + invalidRequest = "InvalidRequestContentException", + notFound = "ResourceNotFoundException", +} + +const supportedMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"]; +const apgNullMsg = JSON.stringify({ Message: null }); + +export const parseClientContext = (contextAsBase64?: string) => { + if (!contextAsBase64) { + return; + } + + try { + const decoded = Buffer.from(contextAsBase64 as string, "base64").toString("utf-8"); + const parsed = JSON.parse(decoded); + return parsed; + } catch (error) { + return new Error(errorType.invalidRequest); + } +}; + +export const unsupportedMethod = (res: ServerResponse, method: string) => { + if (!supportedMethods.includes(method)) { + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(apgNullMsg); + return true; + } +}; + +export const collectBody = async (req: IncomingMessage, isBase64?: boolean) => { + let buf: Buffer | undefined = undefined; + req.on("data", (chunk) => { + buf = typeof buf === "undefined" ? chunk : Buffer.concat([buf, chunk]); + }); + + const body: string | undefined = await new Promise((resolve) => { + req.on("end", async () => { + resolve(buf ? buf.toString(isBase64 ? "base64" : "utf-8") : undefined); + }); + }); + return body; +}; + +export const parseBody = (collecteBody: any) => { + let body; + try { + body = JSON.parse(collecteBody); + } catch (error: any) { + if (body && body.length) { + body = error; + } else { + body = {}; + } + } + return body; +}; + +export const isStreamResponse = (result: any) => { + return result instanceof BufferedStreamResponse; +}; +export const chunkToJs = (chunk: Uint8Array) => { + let data; + let rawData = Buffer.from(chunk).toString(); + try { + data = JSON.parse(rawData); + } catch (error) { + data = rawData; + } + + return data; +}; +export const setRequestId = (res: ServerResponse) => { + const awsRequestId = randomUUID(); + + res.setHeader("x-amzn-RequestId", awsRequestId); + return awsRequestId; +}; + +export const invalidPayloadErrorMsg = (msg: string) => { + return JSON.stringify({ + Type: "User", + message: `Could not parse request body into json: Could not parse payload into json: ${msg}: was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')`, + }); +}; + +export const base64ErorMsg = JSON.stringify({ Type: null, message: "Client context must be a valid Base64-encoded JSON object." }); +export const notFound = (lambdaName: string) => { + return JSON.stringify({ + Type: "User", + message: `Function not found: arn:aws:lambda:region:000000000000:function:${lambdaName}`, + }); +}; + +export const internalServerError = (res: ServerResponse) => { + if (!res.writableFinished) { + res.statusCode = 502; + if (!res.headersSent) { + res.setHeader("Content-Type", "application/json"); + } + + res.end("Internal Server Error"); + } +}; + +export const isDelimiter = (chunk: Buffer) => { + return chunk.byteLength == 8 && Array.from(chunk.values()).every((x) => x === 0); +}; + +export const getMultiValueHeaders = (rawHeaders: string[]) => { + let multiValueHeaders: any = {}; + const multiKeys = rawHeaders.filter((x, i) => i % 2 == 0).map((x) => x.toLowerCase()); + const multiValues = rawHeaders.filter((x, i) => i % 2 !== 0); + + multiKeys.forEach((x, i) => { + if (x == "x-mock-type") { + return; + } + if (multiValueHeaders[x]) { + multiValueHeaders[x].push(multiValues[i]); + } else { + multiValueHeaders[x] = [multiValues[i]]; + } + }); + + return multiValueHeaders; +}; + +const headerError = new Error(headerTooLarge); + +const maxHeaderSize = { + alb: 65536, + apg: 10240, + url: 10240, +}; + +const singleAlbHeaderSize = 16376; + +export const checkHeaders = (headers: { [key: string]: any }, kind: LambdaEndpoint["kind"]) => { + if (!headers.host) { + throw new Error(badRequest); + } + let total = 0; + const maximumAllowedSize = maxHeaderSize[kind]; + const entries = Object.entries(headers); + + if (kind == "alb") { + entries.forEach((entry) => { + const [k, v] = entry; + if (v == "x-mock-type") { + return; + } + const headerLength = k.length + v.length; + if (headerLength > singleAlbHeaderSize) { + throw headerError; + } + + total = total + headerLength; + }); + } else { + entries.forEach((entry) => { + const [k, v] = entry; + if (v == "x-mock-type") { + return; + } + total = total + k.length + v.length; + }); + } + if (total > maximumAllowedSize) { + throw headerError; + } +}; + +export const capitalize = (word: string) => { + const capitalized = word.charAt(0).toUpperCase() + word.slice(1); + return capitalized; +}; diff --git a/yarn.lock b/yarn.lock index 14c7b31..0e8f1f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,141 +2,177 @@ # yarn lockfile v1 -"@esbuild/android-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.15.tgz#893ad71f3920ccb919e1757c387756a9bca2ef42" - integrity sha512-0kOB6Y7Br3KDVgHeg8PRcvfLkq+AccreK///B4Z6fNZGr/tNHX0z2VywCc7PTeWp+bPvjA5WMvNXltHw5QjAIA== - -"@esbuild/android-arm@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.15.tgz#143e0d4e4c08c786ea410b9a7739779a9a1315d8" - integrity sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg== - -"@esbuild/android-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.15.tgz#d2d12a7676b2589864281b2274355200916540bc" - integrity sha512-MzDqnNajQZ63YkaUWVl9uuhcWyEyh69HGpMIrf+acR4otMkfLJ4sUCxqwbCyPGicE9dVlrysI3lMcDBjGiBBcQ== - -"@esbuild/darwin-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.15.tgz#2e88e79f1d327a2a7d9d06397e5232eb0a473d61" - integrity sha512-7siLjBc88Z4+6qkMDxPT2juf2e8SJxmsbNVKFY2ifWCDT72v5YJz9arlvBw5oB4W/e61H1+HDB/jnu8nNg0rLA== - -"@esbuild/darwin-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz#9384e64c0be91388c57be6d3a5eaf1c32a99c91d" - integrity sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg== - -"@esbuild/freebsd-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.15.tgz#2ad5a35bc52ebd9ca6b845dbc59ba39647a93c1a" - integrity sha512-Xk9xMDjBVG6CfgoqlVczHAdJnCs0/oeFOspFap5NkYAmRCT2qTn1vJWA2f419iMtsHSLm+O8B6SLV/HlY5cYKg== - -"@esbuild/freebsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.15.tgz#b513a48446f96c75fda5bef470e64d342d4379cd" - integrity sha512-3TWAnnEOdclvb2pnfsTWtdwthPfOz7qAfcwDLcfZyGJwm1SRZIMOeB5FODVhnM93mFSPsHB9b/PmxNNbSnd0RQ== - -"@esbuild/linux-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.15.tgz#9697b168175bfd41fa9cc4a72dd0d48f24715f31" - integrity sha512-T0MVnYw9KT6b83/SqyznTs/3Jg2ODWrZfNccg11XjDehIved2oQfrX/wVuev9N936BpMRaTR9I1J0tdGgUgpJA== - -"@esbuild/linux-arm@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.15.tgz#5b22062c54f48cd92fab9ffd993732a52db70cd3" - integrity sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw== - -"@esbuild/linux-ia32@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.15.tgz#eb28a13f9b60b5189fcc9e98e1024f6b657ba54c" - integrity sha512-wp02sHs015T23zsQtU4Cj57WiteiuASHlD7rXjKUyAGYzlOKDAjqK6bk5dMi2QEl/KVOcsjwL36kD+WW7vJt8Q== - -"@esbuild/linux-loong64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.15.tgz#32454bdfe144cf74b77895a8ad21a15cb81cfbe5" - integrity sha512-k7FsUJjGGSxwnBmMh8d7IbObWu+sF/qbwc+xKZkBe/lTAF16RqxRCnNHA7QTd3oS2AfGBAnHlXL67shV5bBThQ== - -"@esbuild/linux-mips64el@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.15.tgz#af12bde0d775a318fad90eb13a0455229a63987c" - integrity sha512-ZLWk6czDdog+Q9kE/Jfbilu24vEe/iW/Sj2d8EVsmiixQ1rM2RKH2n36qfxK4e8tVcaXkvuV3mU5zTZviE+NVQ== - -"@esbuild/linux-ppc64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.15.tgz#34c5ed145b2dfc493d3e652abac8bd3baa3865a5" - integrity sha512-mY6dPkIRAiFHRsGfOYZC8Q9rmr8vOBZBme0/j15zFUKM99d4ILY4WpOC7i/LqoY+RE7KaMaSfvY8CqjJtuO4xg== - -"@esbuild/linux-riscv64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.15.tgz#87bd515e837f2eb004b45f9e6a94dc5b93f22b92" - integrity sha512-EcyUtxffdDtWjjwIH8sKzpDRLcVtqANooMNASO59y+xmqqRYBBM7xVLQhqF7nksIbm2yHABptoioS9RAbVMWVA== - -"@esbuild/linux-s390x@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.15.tgz#20bf7947197f199ddac2ec412029a414ceae3aa3" - integrity sha512-BuS6Jx/ezxFuHxgsfvz7T4g4YlVrmCmg7UAwboeyNNg0OzNzKsIZXpr3Sb/ZREDXWgt48RO4UQRDBxJN3B9Rbg== - -"@esbuild/linux-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.15.tgz#31b93f9c94c195e852c20cd3d1914a68aa619124" - integrity sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg== - -"@esbuild/netbsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.15.tgz#8da299b3ac6875836ca8cdc1925826498069ac65" - integrity sha512-R6fKjtUysYGym6uXf6qyNephVUQAGtf3n2RCsOST/neIwPqRWcnc3ogcielOd6pT+J0RDR1RGcy0ZY7d3uHVLA== - -"@esbuild/openbsd-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.15.tgz#04a1ec3d4e919714dba68dcf09eeb1228ad0d20c" - integrity sha512-mVD4PGc26b8PI60QaPUltYKeSX0wxuy0AltC+WCTFwvKCq2+OgLP4+fFd+hZXzO2xW1HPKcytZBdjqL6FQFa7w== - -"@esbuild/sunos-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.15.tgz#6694ebe4e16e5cd7dab6505ff7c28f9c1c695ce5" - integrity sha512-U6tYPovOkw3459t2CBwGcFYfFRjivcJJc1WC8Q3funIwX8x4fP+R6xL/QuTPNGOblbq/EUDxj9GU+dWKX0oWlQ== - -"@esbuild/win32-arm64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.15.tgz#1f95b2564193c8d1fee8f8129a0609728171d500" - integrity sha512-W+Z5F++wgKAleDABemiyXVnzXgvRFs+GVKThSI+mGgleLWluv0D7Diz4oQpgdpNzh4i2nNDzQtWbjJiqutRp6Q== - -"@esbuild/win32-ia32@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.15.tgz#c362b88b3df21916ed7bcf75c6d09c6bf3ae354a" - integrity sha512-Muz/+uGgheShKGqSVS1KsHtCyEzcdOn/W/Xbh6H91Etm+wiIfwZaBn1W58MeGtfI8WA961YMHFYTthBdQs4t+w== - -"@esbuild/win32-x64@0.17.15": - version "0.17.15" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.15.tgz#c2e737f3a201ebff8e2ac2b8e9f246b397ad19b8" - integrity sha512-DjDa9ywLUUmjhV2Y9wUTIF+1XsmuFGvZoCmOWkli1XcNAh5t25cc7fgsCx4Zi/Uurep3TTLyDiKATgGEg61pkA== - -"@types/archiver@^5.3.1": - version "5.3.1" - resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-5.3.1.tgz#02991e940a03dd1a32678fead4b4ca03d0e387ca" - integrity sha512-wKYZaSXaDvTZuInAWjCeGG7BEAgTWG2zZW0/f7IYFcoHB2X2d9lkVFnrOlXl3W6NrvO6Ml3FLLu8Uksyymcpnw== +"@aws-crypto/crc32@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa" + integrity sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA== dependencies: - "@types/glob" "*" + "@aws-crypto/util" "^3.0.0" + "@aws-sdk/types" "^3.222.0" + tslib "^1.11.1" -"@types/glob@*": - version "8.0.0" - resolved "https://registry.yarnpkg.com/@types/glob/-/glob-8.0.0.tgz#321607e9cbaec54f687a0792b2d1d370739455d2" - integrity sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA== +"@aws-crypto/util@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-3.0.0.tgz#1c7ca90c29293f0883468ad48117937f0fe5bfb0" + integrity sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w== dependencies: - "@types/minimatch" "*" - "@types/node" "*" + "@aws-sdk/types" "^3.222.0" + "@aws-sdk/util-utf8-browser" "^3.0.0" + tslib "^1.11.1" + +"@aws-sdk/eventstream-codec@^3.310.0": + version "3.310.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-codec/-/eventstream-codec-3.310.0.tgz#a5def3633f7ccdc3d477fd0b05e2eb31c5598ed9" + integrity sha512-clIeSgWbZbxwtsxZ/yoedNM0/kJFSIjjHPikuDGhxhqc+vP6TN3oYyVMFrYwFaTFhk2+S5wZcWYMw8Op1pWo+A== + dependencies: + "@aws-crypto/crc32" "3.0.0" + "@aws-sdk/types" "3.310.0" + "@aws-sdk/util-hex-encoding" "3.310.0" + tslib "^2.5.0" + +"@aws-sdk/types@3.310.0", "@aws-sdk/types@^3.222.0": + version "3.310.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.310.0.tgz#b83a0580feb38b58417abb8b4ed3eae1a0cb7bc1" + integrity sha512-j8eamQJ7YcIhw7fneUfs8LYl3t01k4uHi4ZDmNRgtbmbmTTG3FZc2MotStZnp3nZB6vLiPF1o5aoJxWVvkzS6A== + dependencies: + tslib "^2.5.0" + +"@aws-sdk/util-hex-encoding@3.310.0": + version "3.310.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.310.0.tgz#19294c78986c90ae33f04491487863dc1d33bd87" + integrity sha512-sVN7mcCCDSJ67pI1ZMtk84SKGqyix6/0A1Ab163YKn+lFBQRMKexleZzpYzNGxYzmQS6VanP/cfU7NiLQOaSfA== + dependencies: + tslib "^2.5.0" + +"@aws-sdk/util-utf8-browser@^3.0.0": + version "3.259.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz#3275a6f5eb334f96ca76635b961d3c50259fd9ff" + integrity sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw== + dependencies: + tslib "^2.3.1" + +"@esbuild/android-arm64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.18.tgz#4aa8d8afcffb4458736ca9b32baa97d7cb5861ea" + integrity sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw== + +"@esbuild/android-arm@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.18.tgz#74a7e95af4ee212ebc9db9baa87c06a594f2a427" + integrity sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw== + +"@esbuild/android-x64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.18.tgz#1dcd13f201997c9fe0b204189d3a0da4eb4eb9b6" + integrity sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg== + +"@esbuild/darwin-arm64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.18.tgz#444f3b961d4da7a89eb9bd35cfa4415141537c2a" + integrity sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ== + +"@esbuild/darwin-x64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.18.tgz#a6da308d0ac8a498c54d62e0b2bfb7119b22d315" + integrity sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A== + +"@esbuild/freebsd-arm64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.18.tgz#b83122bb468889399d0d63475d5aea8d6829c2c2" + integrity sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA== + +"@esbuild/freebsd-x64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.18.tgz#af59e0e03fcf7f221b34d4c5ab14094862c9c864" + integrity sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew== + +"@esbuild/linux-arm64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.18.tgz#8551d72ba540c5bce4bab274a81c14ed01eafdcf" + integrity sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ== + +"@esbuild/linux-arm@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.18.tgz#e09e76e526df4f665d4d2720d28ff87d15cdf639" + integrity sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg== + +"@esbuild/linux-ia32@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.18.tgz#47878860ce4fe73a36fd8627f5647bcbbef38ba4" + integrity sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ== + +"@esbuild/linux-loong64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.18.tgz#3f8fbf5267556fc387d20b2e708ce115de5c967a" + integrity sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ== + +"@esbuild/linux-mips64el@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.18.tgz#9d896d8f3c75f6c226cbeb840127462e37738226" + integrity sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA== + +"@esbuild/linux-ppc64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.18.tgz#3d9deb60b2d32c9985bdc3e3be090d30b7472783" + integrity sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ== + +"@esbuild/linux-riscv64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.18.tgz#8a943cf13fd24ff7ed58aefb940ef178f93386bc" + integrity sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA== + +"@esbuild/linux-s390x@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.18.tgz#66cb01f4a06423e5496facabdce4f7cae7cb80e5" + integrity sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw== + +"@esbuild/linux-x64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.18.tgz#23c26050c6c5d1359c7b774823adc32b3883b6c9" + integrity sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA== + +"@esbuild/netbsd-x64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.18.tgz#789a203d3115a52633ff6504f8cbf757f15e703b" + integrity sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg== + +"@esbuild/openbsd-x64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.18.tgz#d7b998a30878f8da40617a10af423f56f12a5e90" + integrity sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA== + +"@esbuild/sunos-x64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.18.tgz#ecad0736aa7dae07901ba273db9ef3d3e93df31f" + integrity sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg== + +"@esbuild/win32-arm64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.18.tgz#58dfc177da30acf956252d7c8ae9e54e424887c4" + integrity sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg== + +"@esbuild/win32-ia32@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.18.tgz#340f6163172b5272b5ae60ec12c312485f69232b" + integrity sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw== + +"@esbuild/win32-x64@0.17.18": + version "0.17.18" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.18.tgz#3a8e57153905308db357fd02f57c180ee3a0a1fa" + integrity sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg== + +"@types/archiver@^5.3.2": + version "5.3.2" + resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-5.3.2.tgz#a9f0bcb0f0b991400e7766d35f6e19d163bdadcc" + integrity sha512-IctHreBuWE5dvBDz/0WeKtyVKVRs4h75IblxOACL92wU66v+HGAfEYAOyXkOFphvRJMhuXdI9huDXpX0FC6lCw== + dependencies: + "@types/readdir-glob" "*" "@types/mime@*": version "3.0.1" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== -"@types/minimatch@*": - version "5.1.2" - resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" - integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== - "@types/node@*": version "18.11.13" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.13.tgz#dff34f226ec1ac0432ae3b136ec5552bd3b9c0fe" @@ -147,6 +183,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.34.tgz#cd2e6fa0dbfb08a62582a7b967558e73c32061ec" integrity sha512-hcU9AIQVHmPnmjRK+XUUYlILlr9pQrsqSrwov/JK1pnf3GTQowVBhx54FbvM0AU/VXGH4i3+vgXS5EguR7fysA== +"@types/readdir-glob@*": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/readdir-glob/-/readdir-glob-1.1.1.tgz#27ac2db283e6aa3d110b14ff9da44fcd1a5c38b1" + integrity sha512-ImM6TmoF8bgOwvehGviEj3tRdRBbQujr1N+0ypaln/GWjaerOB26jb93vsRHmdMtvVQZQebOlqt2HROark87mQ== + dependencies: + "@types/node" "*" + "@types/serve-static@^1.15.1": version "1.15.1" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.1.tgz#86b1753f0be4f9a1bee68d459fcda5be4ea52b5d" @@ -308,33 +351,33 @@ end-of-stream@^1.4.1: dependencies: once "^1.4.0" -esbuild@0.17.15: - version "0.17.15" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.15.tgz#209ebc87cb671ffb79574db93494b10ffaf43cbc" - integrity sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw== +esbuild@0.17.18: + version "0.17.18" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.18.tgz#f4f8eb6d77384d68cd71c53eb6601c7efe05e746" + integrity sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w== optionalDependencies: - "@esbuild/android-arm" "0.17.15" - "@esbuild/android-arm64" "0.17.15" - "@esbuild/android-x64" "0.17.15" - "@esbuild/darwin-arm64" "0.17.15" - "@esbuild/darwin-x64" "0.17.15" - "@esbuild/freebsd-arm64" "0.17.15" - "@esbuild/freebsd-x64" "0.17.15" - "@esbuild/linux-arm" "0.17.15" - "@esbuild/linux-arm64" "0.17.15" - "@esbuild/linux-ia32" "0.17.15" - "@esbuild/linux-loong64" "0.17.15" - "@esbuild/linux-mips64el" "0.17.15" - "@esbuild/linux-ppc64" "0.17.15" - "@esbuild/linux-riscv64" "0.17.15" - "@esbuild/linux-s390x" "0.17.15" - "@esbuild/linux-x64" "0.17.15" - "@esbuild/netbsd-x64" "0.17.15" - "@esbuild/openbsd-x64" "0.17.15" - "@esbuild/sunos-x64" "0.17.15" - "@esbuild/win32-arm64" "0.17.15" - "@esbuild/win32-ia32" "0.17.15" - "@esbuild/win32-x64" "0.17.15" + "@esbuild/android-arm" "0.17.18" + "@esbuild/android-arm64" "0.17.18" + "@esbuild/android-x64" "0.17.18" + "@esbuild/darwin-arm64" "0.17.18" + "@esbuild/darwin-x64" "0.17.18" + "@esbuild/freebsd-arm64" "0.17.18" + "@esbuild/freebsd-x64" "0.17.18" + "@esbuild/linux-arm" "0.17.18" + "@esbuild/linux-arm64" "0.17.18" + "@esbuild/linux-ia32" "0.17.18" + "@esbuild/linux-loong64" "0.17.18" + "@esbuild/linux-mips64el" "0.17.18" + "@esbuild/linux-ppc64" "0.17.18" + "@esbuild/linux-riscv64" "0.17.18" + "@esbuild/linux-s390x" "0.17.18" + "@esbuild/linux-x64" "0.17.18" + "@esbuild/netbsd-x64" "0.17.18" + "@esbuild/openbsd-x64" "0.17.18" + "@esbuild/sunos-x64" "0.17.18" + "@esbuild/win32-arm64" "0.17.18" + "@esbuild/win32-ia32" "0.17.18" + "@esbuild/win32-x64" "0.17.18" escape-html@~1.0.3: version "1.0.3" @@ -620,10 +663,20 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -typescript@^4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +tslib@^1.11.1: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tslib@^2.3.1, tslib@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" + integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== + +typescript@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" + integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2"