From 0b75de7398cbbdf36c5087485e43a41a189afbe7 Mon Sep 17 00:00:00 2001 From: Scott Davey Date: Wed, 15 Jan 2025 15:19:17 +1100 Subject: [PATCH 1/7] added more granular build commands for convenience --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e762550..f457349 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,10 @@ "dist" ], "scripts": { - "build": "tsc && docker buildx build --load -t bref/local-api-gateway .", + "code-build": "tsc", + "docker-build": "docker buildx build --load -t bref/local-api-gateway .", "docker-publish": "npm run build && docker buildx build --push --platform linux/amd64,linux/arm64 -t bref/local-api-gateway .", + "build": "code-build && docker-build", "lint": "eslint .", "test": "vitest" }, From 1669ddec4caa24b394ca4e34db0bd3cf2cfa3ddc Mon Sep 17 00:00:00 2001 From: Scott Davey Date: Wed, 15 Jan 2025 15:32:36 +1100 Subject: [PATCH 2/7] added docker execution mode; added debug and info logging --- Dockerfile | 3 ++ README.md | 31 +++++++++++++++++++- src/index.ts | 83 +++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index b0b0d95..737c5f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,9 @@ FROM node:18-alpine ENV AWS_ACCESS_KEY_ID='fake' ENV AWS_SECRET_ACCESS_KEY='fake' +# Install Docker CLI +RUN apk add --no-cache docker-cli + WORKDIR /app COPY package.json package.json RUN npm install --production diff --git a/README.md b/README.md index c01651b..eb6a486 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,28 @@ services: # ... ``` +## Using `docker exec` to run multiple commands at once + +Normally local-api-gateway runs using the official Lambda RIE, which only supports a single execution at a time. To support +concurrent execution you can switch to use Docker Exec. + +```yaml +services: + web: + image: bref/local-api-gateway + ports: ['8000:8000'] + volumes: + - .:/var/task:ro + environment: + TARGET: 'php:8080' + TARGET_CONTAINER: 'my-php-container' # if different to 'php' above + TARGET_HANDLER: '/path/to/vendor/bin/bref-local handler.php' # here, handler.php is in /var/task and bref-local is elsewhere +``` + +## Logging + +You can log the processing for visibility during development by setting `LOG_LEVEL` to one of `none`, `info` or `debug`. + ## FAQ ### This vs Serverless Offline @@ -96,6 +118,13 @@ No, this is a very simple HTTP server. It does not support API Gateway features ### How are parallel requests handled? -The Lambda RIE does not support parallel requests. This project handles them by "queueing" requests. If a request is already being processed, the next request will be queued and processed when the first request is done. +This system offers two execution modes: +- Lambda RIE: one request runs at a time, queuing additional requests +- Docker Exec: handles parallel requests + +The Lambda RIE does not support parallel requests. This project handles them by "queueing" requests. If a request is already being processed, the next request will be queued and processed when the first request is done. This works up to 10 requests in parallel by default. You can change this limit by setting the `DEV_MAX_REQUESTS_IN_PARALLEL` environment variable. + +The Docker Exec mode fires `docker exec` on the target container for each request, so can handle parallel requests up to the limits in the target container. Although useful for development, keep in mind that this is not the same as parallel requests in AWS Lambda, as in AWS each Lambda function runs as a single isolated request. +To activate this mode, define `TARGET_CONTAINER` and `TARGET_HANDLER` environment variables. diff --git a/src/index.ts b/src/index.ts index 28f5ee4..1509ddc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,11 @@ import express, { NextFunction, Request, Response } from 'express'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import queue from 'express-queue'; +import { promisify } from 'util'; +import { exec } from 'child_process'; import { APIGatewayProxyStructuredResultV2 } from 'aws-lambda'; import { InvocationType, InvokeCommand, InvokeCommandOutput, LambdaClient } from '@aws-sdk/client-lambda'; -import { httpRequestToEvent } from './apiGateway'; +import { httpRequestToEvent } from './apiGateway.js'; import bodyParser from 'body-parser'; const app = express(); @@ -15,6 +17,10 @@ if (process.env.DOCUMENT_ROOT) { app.use(express.static(process.env.DOCUMENT_ROOT)); } +// Logging levels: 'none', 'info', 'debug' +const doInfoLogging = ['info', 'debug'].includes(process.env.LOG_LEVEL ?? 'none'); +const doDebugLogging = process.env.LOG_LEVEL === 'debug'; + // Prevent parallel requests as Lambda RIE can only handle one request at a time // The solution here is to use a request "queue": // incoming requests are queued until the previous request is finished. @@ -35,11 +41,28 @@ const requestQueue = queue({ app.use(requestQueue); const target = process.env.TARGET; -if (!target) { +if (!target || !target.includes(":")) { throw new Error( 'The TARGET environment variable must be set and contain the domain + port of the target lambda container (for example, "localhost:9000")' ); } + +// Determine whether to use Docker CLI or AWS Lambda RIE for execution +const dockerHost = process.env.TARGET_CONTAINER ?? target.split(":")[0]; +const dockerHandler = process.env.TARGET_HANDLER; +const mode = (dockerHost && dockerHandler) ? "docker": "rie"; +if (doInfoLogging) { + if (mode === "docker") { + console.log(`Using docker CLI on '${dockerHost}' via '${dockerHandler}'`); + } else { + console.log("Using AWS Lambda RIE environment - set TARGET_CONTAINER and TARGET_HANDLER environment variables to enable docker CLI mode"); + } +} +const isBrefLocalHandler = dockerHandler?.includes('bref-local') ?? false; + +// Create an async version of exec() for calling Docker +const asyncExec = promisify(exec); + const client = new LambdaClient({ region: 'us-east-1', endpoint: `http://${target}`, @@ -62,25 +85,65 @@ app.use(bodyParser.raw({ app.all('*', async (req: Request, res: Response, next) => { const event = httpRequestToEvent(req); - let result: InvokeCommandOutput; + let result: string; + const requestContext = event?.requestContext?.http ?? {}; try { - result = await client.send( - new InvokeCommand({ + const payload = Buffer.from(JSON.stringify(event)).toString(); + if (doInfoLogging) { + console.log(`START [${mode.toUpperCase()}] ${requestContext?.method} ${requestContext?.path}`, payload); + } + + if (mode === "docker") { + const payloadAsEscapedJson = payload.replace("'", "\\'"); + const dockerCommand = `/usr/bin/docker exec ${dockerHost} ${dockerHandler} '${payloadAsEscapedJson}'`; + const {stdout, stderr} = await asyncExec(dockerCommand); + result = Buffer.from(stdout).toString(); + + if (isBrefLocalHandler) { + // The 'bref-local' handler returns the following, which needs to be stripped: + // START + // END Duration XXXXX + // (blank line) + // ...real output... + result = result + .split('\n') // Split the output into lines + .slice(3) // Skip the first three lines + .join('\n'); // Join the remaining lines back together + } + if (doInfoLogging) { + console.log(`END [DOCKER] ${requestContext?.method} ${requestContext?.path}`, result); + if (doDebugLogging) { + console.log(`END [DOCKER] CMD `, dockerCommand); + console.log(`END [DOCKER] STDOUT`, stdout); + if (stderr) { + console.error(`END [DOCKER] STDERR: ${stderr}`); + } + } + } + } else { + const invokeCommand = new InvokeCommand({ FunctionName: 'function', - Payload: Buffer.from(JSON.stringify(event)), + Payload: payload, InvocationType: InvocationType.RequestResponse, - }) - ); + }); + const invokeResponse: InvokeCommandOutput = await client.send(invokeCommand); + result = String(invokeResponse.Payload); + if (doDebugLogging) { + console.log(`END [RIE] ${requestContext?.method} ${requestContext?.path}`, result); + } + } + } catch (e) { + console.error(`END [ERROR] ${requestContext?.method} ${requestContext?.path}`, e); res.send(JSON.stringify(e)); return next(e); } - if (!result.Payload) { + if (!result) { return res.status(500).send('No payload in Lambda response'); } - const payload = Buffer.from(result.Payload).toString(); + const payload = Buffer.from(result).toString(); let lambdaResponse: APIGatewayProxyStructuredResultV2; try { lambdaResponse = JSON.parse(payload) as APIGatewayProxyStructuredResultV2; From 21f92771b45256fd9e69ff4d420919d63d2a8103 Mon Sep 17 00:00:00 2001 From: Scott Davey Date: Thu, 16 Jan 2025 15:11:33 +1100 Subject: [PATCH 3/7] Set up parallel execution --- README.md | 8 +++++--- src/index.ts | 54 +++++++++++++++++++++++++++------------------------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index eb6a486..b14da4a 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,11 @@ services: volumes: - .:/var/task:ro environment: - TARGET: 'php:8080' - TARGET_CONTAINER: 'my-php-container' # if different to 'php' above - TARGET_HANDLER: '/path/to/vendor/bin/bref-local handler.php' # here, handler.php is in /var/task and bref-local is elsewhere + TARGET: 'php:8080' # service:port + TARGET_CONTAINER: 'my-php-container' # specify if different to the host within TARGET + TARGET_HANDLER: '/path/to/vendor/bin/bref-local handler.php' # The handler within /var/task; bref-local can be elsewhere + DEV_MAX_REQUESTS_IN_PARALLEL: 10 # number to run simultaneously + DEV_MAX_REQUESTS_IN_QUEUE: 20 # number to queue when capacity is reached ``` ## Logging diff --git a/src/index.ts b/src/index.ts index 1509ddc..4ab0a94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,29 +17,6 @@ if (process.env.DOCUMENT_ROOT) { app.use(express.static(process.env.DOCUMENT_ROOT)); } -// Logging levels: 'none', 'info', 'debug' -const doInfoLogging = ['info', 'debug'].includes(process.env.LOG_LEVEL ?? 'none'); -const doDebugLogging = process.env.LOG_LEVEL === 'debug'; - -// Prevent parallel requests as Lambda RIE can only handle one request at a time -// The solution here is to use a request "queue": -// incoming requests are queued until the previous request is finished. -// See https://github.com/brefphp/bref/issues/1471 -const requestQueue = queue({ - // max requests to process simultaneously - activeLimit: 1, - // max requests in queue until reject (-1 means do not reject) - queuedLimit: process.env.DEV_MAX_REQUESTS_IN_PARALLEL ?? 10, - // handler to call when queuedLimit is reached (see below) - rejectHandler: (req: Request, res: Response) => { - res.status(503); - res.send( - 'Too many requests in parallel, set the `DEV_MAX_REQUESTS_IN_PARALLEL` environment variable to increase the limit' - ); - }, -}); -app.use(requestQueue); - const target = process.env.TARGET; if (!target || !target.includes(":")) { throw new Error( @@ -47,6 +24,10 @@ if (!target || !target.includes(":")) { ); } +// Logging levels: 'none', 'info', 'debug' +const doInfoLogging = ['info', 'debug'].includes(process.env.LOG_LEVEL ?? 'none'); +const doDebugLogging = process.env.LOG_LEVEL === 'debug'; + // Determine whether to use Docker CLI or AWS Lambda RIE for execution const dockerHost = process.env.TARGET_CONTAINER ?? target.split(":")[0]; const dockerHandler = process.env.TARGET_HANDLER; @@ -59,6 +40,27 @@ if (doInfoLogging) { } } const isBrefLocalHandler = dockerHandler?.includes('bref-local') ?? false; +const maxParallelRequests = process.env.DEV_MAX_REQUESTS_IN_PARALLEL ?? 10; +const maxQueuedRequests = process.env.DEV_MAX_REQUESTS_IN_QUEUE ?? -1; + +// Prevent parallel requests as Lambda RIE can only handle one request at a time +// The solution here is to use a request "queue": +// incoming requests are queued until the previous request is finished. +// See https://github.com/brefphp/bref/issues/1471 +const requestQueue = queue({ + // max requests to process simultaneously (set to 1 for RIE mode) + activeLimit: (mode === "docker") ? maxParallelRequests : 1, + // max requests in queue until reject (-1 means do not reject) + queuedLimit: maxQueuedRequests, + // handler to call when queuedLimit is reached (see below) + rejectHandler: (req: Request, res: Response) => { + res.status(503); + res.send( + 'Too many requests in parallel, set the `DEV_MAX_REQUESTS_IN_PARALLEL` and `DEV_MAX_REQUESTS_IN_QUEUE` environment variables to control the limit' + ); + }, +}); +app.use(requestQueue); // Create an async version of exec() for calling Docker const asyncExec = promisify(exec); @@ -90,7 +92,7 @@ app.all('*', async (req: Request, res: Response, next) => { try { const payload = Buffer.from(JSON.stringify(event)).toString(); if (doInfoLogging) { - console.log(`START [${mode.toUpperCase()}] ${requestContext?.method} ${requestContext?.path}`, payload); + console.log(`START [${mode.toUpperCase()}] ${requestContext?.method} ${requestContext?.path}`, doDebugLogging ? payload : null); } if (mode === "docker") { @@ -111,7 +113,7 @@ app.all('*', async (req: Request, res: Response, next) => { .join('\n'); // Join the remaining lines back together } if (doInfoLogging) { - console.log(`END [DOCKER] ${requestContext?.method} ${requestContext?.path}`, result); + console.log(`END [DOCKER] ${requestContext?.method} ${requestContext?.path}`, doDebugLogging ? result : null); if (doDebugLogging) { console.log(`END [DOCKER] CMD `, dockerCommand); console.log(`END [DOCKER] STDOUT`, stdout); @@ -129,7 +131,7 @@ app.all('*', async (req: Request, res: Response, next) => { const invokeResponse: InvokeCommandOutput = await client.send(invokeCommand); result = String(invokeResponse.Payload); if (doDebugLogging) { - console.log(`END [RIE] ${requestContext?.method} ${requestContext?.path}`, result); + console.log(`END [RIE] ${requestContext?.method} ${requestContext?.path}`, doDebugLogging ? result : null); } } From 58149b88ade46f1ebf280525f5956f29a0fb9c63 Mon Sep 17 00:00:00 2001 From: Scott Davey Date: Thu, 16 Jan 2025 15:45:39 +1100 Subject: [PATCH 4/7] Switched from exec to spawn to handle large payload responses; improved logging and code style --- src/docker.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 47 ++++++---------------------- 2 files changed, 96 insertions(+), 38 deletions(-) create mode 100644 src/docker.ts diff --git a/src/docker.ts b/src/docker.ts new file mode 100644 index 0000000..6a3d7b9 --- /dev/null +++ b/src/docker.ts @@ -0,0 +1,87 @@ +import { spawn } from 'child_process'; + +/** + * Utility function to promisify spawn + * + * @param command The command to run + * @param args The args as an array of strings + */ +const asyncSpawn = (command: string, args: string[]): Promise<{ stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + const process = spawn(command, args); + let stdout = ''; + let stderr = ''; + + process.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + process.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + process.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`Command exited with code ${code}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`)); + } + }); + + process.on('error', (err) => { + reject(err); + }); + }); +}; + +/** + * Runs the Docker Exec command + * + * @param container The docker container name (e.g. 'php') + * @param handler The handler command (e.g. '/path/to/vendor/bref/bref-local handler.php') + * @param payload The JSON-encoded payload + */ +export const runDockerCommand = async (container: string, handler: string, payload: string): Promise => { + // Build the docker command: '/usr/bin/docker exec $CONTAINER $HANDLER $PAYLOAD' for spawn + const [command, ...handlerArgs] = handler.split(' '); + const dockerCommand = [ + "exec", + container, + command, + ...handlerArgs, + payload, + ]; + + // Run the command and pull the output into a string + let result: string|null = null; + try { + const { stdout, stderr } = await asyncSpawn("/usr/bin/docker", dockerCommand); + if (stderr) { + console.info(`END [DOCKER] COMMAND: `, dockerCommand); + console.error(`END [DOCKER] STDERR: ${stderr}`); + } + result = Buffer.from(stdout).toString(); + } catch (error) { + console.info(`END [DOCKER] COMMAND: `, dockerCommand); + console.error(`END [DOCKER] ERROR: ${(error as Error).message}`); + throw error; + } + + // Strip header info from bref-local output + if (handler?.includes('bref-local')) { + // The 'bref-local' handler returns the following header which needs to be stripped: + // v + // START + // END Duration ... + // + // ^ + // (real output begins under this line) + // + result = result + .split('\n') // Split the output into lines + .slice(3) // Skip the first three lines + .join('\n'); // Join the remaining lines back together + } + + return result; +} diff --git a/src/index.ts b/src/index.ts index 4ab0a94..aaca09e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,10 @@ import express, { NextFunction, Request, Response } from 'express'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import queue from 'express-queue'; -import { promisify } from 'util'; -import { exec } from 'child_process'; import { APIGatewayProxyStructuredResultV2 } from 'aws-lambda'; import { InvocationType, InvokeCommand, InvokeCommandOutput, LambdaClient } from '@aws-sdk/client-lambda'; import { httpRequestToEvent } from './apiGateway.js'; +import { runDockerCommand } from "./docker"; import bodyParser from 'body-parser'; const app = express(); @@ -30,7 +29,7 @@ const doDebugLogging = process.env.LOG_LEVEL === 'debug'; // Determine whether to use Docker CLI or AWS Lambda RIE for execution const dockerHost = process.env.TARGET_CONTAINER ?? target.split(":")[0]; -const dockerHandler = process.env.TARGET_HANDLER; +const dockerHandler = process.env.TARGET_HANDLER ?? ''; const mode = (dockerHost && dockerHandler) ? "docker": "rie"; if (doInfoLogging) { if (mode === "docker") { @@ -39,7 +38,6 @@ if (doInfoLogging) { console.log("Using AWS Lambda RIE environment - set TARGET_CONTAINER and TARGET_HANDLER environment variables to enable docker CLI mode"); } } -const isBrefLocalHandler = dockerHandler?.includes('bref-local') ?? false; const maxParallelRequests = process.env.DEV_MAX_REQUESTS_IN_PARALLEL ?? 10; const maxQueuedRequests = process.env.DEV_MAX_REQUESTS_IN_QUEUE ?? -1; @@ -62,9 +60,6 @@ const requestQueue = queue({ }); app.use(requestQueue); -// Create an async version of exec() for calling Docker -const asyncExec = promisify(exec); - const client = new LambdaClient({ region: 'us-east-1', endpoint: `http://${target}`, @@ -92,37 +87,13 @@ app.all('*', async (req: Request, res: Response, next) => { try { const payload = Buffer.from(JSON.stringify(event)).toString(); if (doInfoLogging) { - console.log(`START [${mode.toUpperCase()}] ${requestContext?.method} ${requestContext?.path}`, doDebugLogging ? payload : null); + console.log(`START [${mode.toUpperCase()}] ${requestContext?.method} ${requestContext?.path}`, doDebugLogging ? payload : ''); } - if (mode === "docker") { - const payloadAsEscapedJson = payload.replace("'", "\\'"); - const dockerCommand = `/usr/bin/docker exec ${dockerHost} ${dockerHandler} '${payloadAsEscapedJson}'`; - const {stdout, stderr} = await asyncExec(dockerCommand); - result = Buffer.from(stdout).toString(); - - if (isBrefLocalHandler) { - // The 'bref-local' handler returns the following, which needs to be stripped: - // START - // END Duration XXXXX - // (blank line) - // ...real output... - result = result - .split('\n') // Split the output into lines - .slice(3) // Skip the first three lines - .join('\n'); // Join the remaining lines back together - } - if (doInfoLogging) { - console.log(`END [DOCKER] ${requestContext?.method} ${requestContext?.path}`, doDebugLogging ? result : null); - if (doDebugLogging) { - console.log(`END [DOCKER] CMD `, dockerCommand); - console.log(`END [DOCKER] STDOUT`, stdout); - if (stderr) { - console.error(`END [DOCKER] STDERR: ${stderr}`); - } - } - } + // Run via Docker + result = await runDockerCommand(dockerHost, dockerHandler, payload); } else { + // Run via Lambda RIE SDK const invokeCommand = new InvokeCommand({ FunctionName: 'function', Payload: payload, @@ -130,9 +101,9 @@ app.all('*', async (req: Request, res: Response, next) => { }); const invokeResponse: InvokeCommandOutput = await client.send(invokeCommand); result = String(invokeResponse.Payload); - if (doDebugLogging) { - console.log(`END [RIE] ${requestContext?.method} ${requestContext?.path}`, doDebugLogging ? result : null); - } + } + if (doInfoLogging) { + console.log(`END [${mode.toUpperCase()}] ${requestContext?.method} ${requestContext?.path}`, doDebugLogging ? result : `${result.length} bytes`); } } catch (e) { From 10f41f401667c8cbea1b20daa46bb5885c4035ba Mon Sep 17 00:00:00 2001 From: Scott Davey Date: Thu, 16 Jan 2025 16:03:05 +1100 Subject: [PATCH 5/7] Updated README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b14da4a..9eca6d2 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ services: image: bref/local-api-gateway ports: ['8000:8000'] volumes: + - /var/run/docker.sock:/var/run/docker.sock - .:/var/task:ro environment: TARGET: 'php:8080' # service:port From 755096d0b2d18c113d308a4268c6d8944bebd561 Mon Sep 17 00:00:00 2001 From: Scott Davey Date: Wed, 5 Feb 2025 18:02:46 +1100 Subject: [PATCH 6/7] Fixed error with local-api-gateway returning invalid JSON data from Bref due to additional header data logged from stdout logging in the application. This strips everything before "END Duration" which guarantees no further output from code other than Bref. --- src/docker.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/docker.ts b/src/docker.ts index 6a3d7b9..00022fc 100644 --- a/src/docker.ts +++ b/src/docker.ts @@ -34,6 +34,24 @@ const asyncSpawn = (command: string, args: string[]): Promise<{ stdout: string; }); }; +/** + * Handles bref-local output headers + * + * The 'bref-local' handler returns the following header which needs to be stripped: + * v + * START + * END Duration ... + * + * ^ + * (real output begins under this line) + * + * @param input + */ +function removeBrefLocalHeaders(input: string): string { + const match = input.match(/END Duration:.*\n([\s\S]*)/); + return match ? match[1].trim() : ""; +} + /** * Runs the Docker Exec command * @@ -69,18 +87,7 @@ export const runDockerCommand = async (container: string, handler: string, paylo // Strip header info from bref-local output if (handler?.includes('bref-local')) { - // The 'bref-local' handler returns the following header which needs to be stripped: - // v - // START - // END Duration ... - // - // ^ - // (real output begins under this line) - // - result = result - .split('\n') // Split the output into lines - .slice(3) // Skip the first three lines - .join('\n'); // Join the remaining lines back together + result = removeBrefLocalHeaders(result); } return result; From 527eddf6766530c21e39b6d9f5e517ed128a2c02 Mon Sep 17 00:00:00 2001 From: Scott Davey Date: Fri, 13 Jun 2025 08:57:49 +1000 Subject: [PATCH 7/7] Updated readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 9eca6d2..d6d953e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ +## Fork + +This is a fork of [bref/local-api-gateway](https://github.com/bref/local-api-gateway). + +This version provides Docker parallelisation, with the new `TARGET_CONTAINER` and `TARGET_HANDLER` variables as documented below. + +## Original content + This project lets you run HTTP Lambda applications locally. ## Why