├── .eslintignore ├── .npmrc ├── .githooks └── pre-push │ └── test.sh ├── lambda.png ├── test ├── lambdas │ ├── runtime-error.js │ ├── go │ │ ├── go.mod │ │ └── hello.go │ ├── syntax-error.js │ ├── echo.js │ ├── hello.py │ ├── malformed.js │ ├── esm.mjs │ └── hello.js ├── index.js ├── server.js ├── errors.js ├── languages.js ├── routing.js ├── common │ └── test-common.js ├── esm.js └── workers.js ├── script ├── hello.js └── run.js ├── .gitignore ├── _types ├── base-tsconfig.json ├── tape-cluster │ └── index.d.ts ├── node-fetch │ └── index.d.ts └── pre-bundled__tape │ └── index.d.ts ├── jsconfig.json ├── workers ├── py-worker-txt.js ├── js-worker-txt.js ├── go-worker-posix-txt.js └── go-worker-windows-txt.js ├── LICENSE ├── .github └── workflows │ └── nodejs.yml ├── package.json ├── README.md ├── child-process-worker.js └── index.js /.eslintignore: -------------------------------------------------------------------------------- 1 | test/lambdas/*.js 2 | workers/go-*.js 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | git-tag-version=false 3 | audit=false 4 | -------------------------------------------------------------------------------- /.githooks/pre-push/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | npm run tsc && npm run lint 4 | -------------------------------------------------------------------------------- /lambda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/socketsupply/fake-api-gateway-lambda/HEAD/lambda.png -------------------------------------------------------------------------------- /test/lambdas/runtime-error.js: -------------------------------------------------------------------------------- 1 | exports.handler = function () { 2 | throw new Error('runtime error') 3 | } 4 | -------------------------------------------------------------------------------- /test/lambdas/go/go.mod: -------------------------------------------------------------------------------- 1 | module go-worker 2 | 3 | go 1.16 4 | 5 | require github.com/aws/aws-lambda-go v1.27.0 6 | -------------------------------------------------------------------------------- /test/lambdas/syntax-error.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-start 2 | 3 | exports.handler = function () { 4 | SYNTAX ERROR 5 | } -------------------------------------------------------------------------------- /script/hello.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function (event) { 2 | return { statusCode: 200, body: '{"okay": true}' } 3 | } 4 | 5 | // SYNTAX ERROR 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | 3 | coverage 4 | node_modules 5 | 6 | test/lambdas/go/hello 7 | 8 | .nyc_output 9 | .vscode 10 | __pycache__/ 11 | go.sum 12 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | require('tapzero').setStrict(true) 5 | 6 | require('./errors.js') 7 | require('./workers.js') 8 | require('./routing.js') 9 | require('./server.js') 10 | require('./languages.js') 11 | -------------------------------------------------------------------------------- /test/lambdas/echo.js: -------------------------------------------------------------------------------- 1 | exports.handler = function (event, _context, callback) { 2 | callback(null, { 3 | statusCode: 200, 4 | headers: { 5 | 'Content-Type': '*/*' 6 | }, 7 | body: event.resource + ' ' + event.path 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /test/lambdas/hello.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | def lambda_handler(event, context): 4 | print('python hello') 5 | 6 | print('event', event) 7 | 8 | # TODO implement 9 | return { 10 | 'statusCode': 200, 11 | 'body': json.dumps('Hello from Lambda! (python)') 12 | } 13 | -------------------------------------------------------------------------------- /test/lambdas/malformed.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | /** 5 | * Malformed.js ; a handler implementation that returns an invalid 6 | * string such that an API Gateway returns an internal server error. 7 | */ 8 | 9 | exports.handler = async () => { 10 | return 'Invalid Non HTTP response string' 11 | } 12 | -------------------------------------------------------------------------------- /_types/base-tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["node"], 4 | "target": "es2018", 5 | "lib": ["es2018"], 6 | "noEmit": true, 7 | "module": "commonjs", 8 | "allowJs": true, 9 | "checkJs": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitReturns": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "strict": false, 15 | "maxNodeModuleJsDepth": 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./_types/base-tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "@pre-bundled/rimraf": ["_types/pre-bundled__rimraf"], 7 | "@pre-bundled/tape": ["_types/pre-bundled__tape"], 8 | "*" : ["_types/*"] 9 | } 10 | }, 11 | "include": [ 12 | "_types/**/*.d.ts", 13 | "*.js", 14 | "test/**/*", 15 | "workers/js-worker-txt.js" 16 | ], 17 | "exclude": [ 18 | "node_modules", 19 | "test/lambdas/hello.js", 20 | "test/lambdas/syntax-error.js", 21 | "test/lambdas/runtime-error.js" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /test/lambdas/go/hello.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/aws/aws-lambda-go/lambda" 9 | ) 10 | 11 | type HelloResponse struct { 12 | StatusCode int `json:"statusCode"` 13 | Body string `json:"body"` 14 | } 15 | 16 | func RequestHandler(ctx context.Context, b json.RawMessage) (HelloResponse, error) { 17 | fmt.Println("hello from lambda") 18 | fmt.Printf("event %s\n", string(b)) 19 | 20 | return HelloResponse{ 21 | StatusCode: 200, 22 | Body: "Hello from Lambda! (go)", 23 | }, nil 24 | } 25 | 26 | func main() { 27 | lambda.Start(RequestHandler) 28 | } 29 | -------------------------------------------------------------------------------- /script/run.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const FakeApiGatewayLambda = 3 | require('../').FakeApiGatewayLambda 4 | // const fetch = require('node-fetch') 5 | 6 | async function test () { 7 | const gateway = new FakeApiGatewayLambda({ 8 | port: 8081, 9 | env: { 10 | TEST_SETTINGS: '...', 11 | TEST_S3_BUCKET: 'some-test-bucket-NOT-PROD', 12 | TEST_DB_NAME: 'my-app-test' 13 | }, 14 | routes: { 15 | '/hello': path.join( 16 | __dirname, 'hello.js' 17 | ) 18 | } 19 | }) 20 | 21 | await gateway.bootstrap() 22 | console.log('gataway running...') 23 | // const resp = await fetch(`http://${gateway.hostPort}/hello`) 24 | 25 | // Payload of the hello-world lambda response. 26 | // const body = await resp.json() 27 | 28 | // await gateway.close() 29 | } 30 | 31 | test() 32 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const { test } = require('tapzero') 5 | 6 | const { FakeApiGatewayLambda } = require('../index.js') 7 | const TestCommon = require('./common/test-common.js') 8 | 9 | test('listening on same port twice returns an err', async (t) => { 10 | const common = await TestCommon.create() 11 | 12 | try { 13 | const lambdaServer = common.lambda 14 | const port = lambdaServer.hostPort.split(':')[1] 15 | 16 | t.ok(port, 'server listens on a port') 17 | 18 | const lambdaServer2 = new FakeApiGatewayLambda({ 19 | port: parseInt(port, 10) 20 | }) 21 | 22 | const r = await lambdaServer2.bootstrap() 23 | t.ok(r, 'r exists') 24 | 25 | t.ok(r.err, 'bootstrap() existing port returns err') 26 | t.equal(r.err.code, 'EADDRINUSE', 27 | 'err code is already listening') 28 | 29 | await lambdaServer2.close() 30 | } finally { 31 | await common.close() 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /workers/py-worker-txt.js: -------------------------------------------------------------------------------- 1 | module.exports = ` 2 | import sys, json, importlib.util 3 | 4 | entry = sys.argv[1] 5 | handler = sys.argv[2] 6 | 7 | event = '' 8 | for line in sys.stdin: 9 | event = json.loads(line) 10 | break 11 | 12 | id = event['id'] 13 | eventObject = event['eventObject'] 14 | 15 | spec = importlib.util.spec_from_file_location( 16 | "module.name", entry 17 | ) 18 | index = importlib.util.module_from_spec(spec) 19 | spec.loader.exec_module(index) 20 | 21 | handlerFn = getattr(index, handler) 22 | 23 | result = handlerFn(eventObject, {}) 24 | 25 | resultObj = { 26 | 'message': 'result', 27 | 'id': id, 28 | 'result': { 29 | 'isBase64Encoded': result.get('isBase64Encoded') or False, 30 | 'statusCode': result.get('statusCode'), 31 | 'headers': result.get('headers') or {}, 32 | 'body': result.get('body') or '', 33 | 'multiValueHeaders': result.get('multiValueHeaders') 34 | } 35 | } 36 | 37 | line = '\\n__FAKE_LAMBDA_START__ ' + json.dumps(resultObj) + '__FAKE_LAMBDA_END__' 38 | 39 | print(line, flush=True) 40 | ` 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Raynos. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build_on_linux: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 5 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Use Node.js 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 14.x 16 | - name: Use Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: ^1.16 20 | - name: npm install 21 | run: npm install 22 | - name: npm test 23 | run: npm test 24 | env: 25 | GOPATH: /home/runner/work/socketsupply/fake-api-gateway-lambda 26 | build_on_windows: 27 | runs-on: windows-2022 28 | timeout-minutes: 5 29 | 30 | steps: 31 | - uses: actions/checkout@v1 32 | - name: Use Node.js 33 | uses: actions/setup-node@v1 34 | with: 35 | node-version: 14.x 36 | - name: Use Go 37 | uses: actions/setup-go@v2 38 | with: 39 | go-version: ^1.16 40 | - name: setup go env stuff 41 | run: | 42 | echo "::set-env name=GOPATH::$(go env GOPATH)" 43 | echo "::add-path::$(go env GOPATH)/bin" 44 | shell: bash 45 | env: 46 | ACTIONS_ALLOW_UNSECURE_COMMANDS: true 47 | - name: npm install 48 | run: npm install 49 | - name: npm test 50 | run: npm test 51 | -------------------------------------------------------------------------------- /_types/tape-cluster/index.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 3.0 2 | 3 | type TestCase = import('@pre-bundled/tape').TestCase 4 | type Test = import('@pre-bundled/tape').Test 5 | 6 | type tapeClusterTestCase = 7 | /* eslint-disable-next-line @typescript-eslint/no-invalid-void-type */ 8 | (harness: Harness, test: Test) => (void | Promise); 9 | 10 | interface TapeClusterFn { 11 | (name: string, cb?: tapeClusterTestCase): void; 12 | ( 13 | name: string, 14 | opts: Options, 15 | cb: tapeClusterTestCase 16 | ): void; 17 | 18 | only(name: string, cb?: tapeClusterTestCase): void; 19 | only( 20 | name: string, 21 | opts: Options, 22 | cb: tapeClusterTestCase 23 | ): void; 24 | 25 | skip(name: string, cb?: tapeClusterTestCase): void; 26 | skip( 27 | name: string, 28 | opts: Options, 29 | cb: tapeClusterTestCase 30 | ): void; 31 | } 32 | 33 | interface TestHarness { 34 | bootstrap(): Promise; 35 | close(): Promise; 36 | } 37 | 38 | declare namespace tapeCluster {} 39 | 40 | declare function tapeCluster ( 41 | tape: ((name: string, cb: TestCase) => void), 42 | harness: (new (opts?: Options) => Harness) 43 | ): TapeClusterFn 44 | 45 | export = tapeCluster 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fake-api-gateway-lambda", 3 | "version": "5.3.13", 4 | "description": "This is a testing utility for testing your lambda functions.", 5 | "scripts": { 6 | "tsc": "tsc -p jsconfig.json --maxNodeModuleJsDepth 0", 7 | "lint": "standard", 8 | "prepare": "node ./node_modules/git-hooks-plus/bin/git-hooks --install", 9 | "test": "npm run tsc && npm run lint && node test/index.js" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "12.0.2", 13 | "standard": "16.0.3", 14 | "typescript": "4.4.4", 15 | "collapsed-assert": "1.0.3", 16 | "git-hooks-plus": "1.0.1", 17 | "node-fetch": "2.6.7", 18 | "tapzero": "0.6.1" 19 | }, 20 | "standard": { 21 | "ignore": [ 22 | "workers/go-worker-txt.js" 23 | ] 24 | }, 25 | "author": "Raynos ", 26 | "repository": "git://github.com/Raynos/fake-api-gateway-lambda.git", 27 | "homepage": "https://github.com/Raynos/fake-api-gateway-lambda", 28 | "bugs": { 29 | "url": "https://github.com/Raynos/fake-api-gateway-lambda/issues", 30 | "email": "raynos2@gmail.com" 31 | }, 32 | "contributors": [ 33 | { 34 | "name": "Raynos" 35 | } 36 | ], 37 | "licenses": [ 38 | { 39 | "type": "MIT", 40 | "url": "http://github.com/Raynos/fake-ses/raw/master/LICENSE" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /test/lambdas/esm.mjs: -------------------------------------------------------------------------------- 1 | export const handler = (event, _, callback) => { 2 | // console.log('Received event:', JSON.stringify(event, null, 2)); 3 | const res = { 4 | statusCode: 200, 5 | headers: { 6 | 'Content-Type': '*/*' 7 | } 8 | } 9 | let greeter = 'World' 10 | console.log('js esm') 11 | console.log(JSON.stringify(event)) 12 | if (event.greeter && event.greeter !== '') { 13 | greeter = event.greeter 14 | } else if (event.body && event.body !== '') { 15 | const body = JSON.parse(event.body) 16 | if (body.greeter && body.greeter !== '') { 17 | greeter = body.greeter 18 | } 19 | } else if (event.queryStringParameters && event.queryStringParameters.greeter && event.queryStringParameters.greeter !== '') { 20 | greeter = event.queryStringParameters.greeter 21 | } else if (event.multiValueHeaders && event.multiValueHeaders.greeter && event.multiValueHeaders.greeter !== '') { 22 | greeter = event.multiValueHeaders.greeter.join(' and ') 23 | } else if (event.headers && event.headers.greeter && event.headers.greeter !== '') { 24 | greeter = event.headers.greeter 25 | } else if (process.env.TEST_GREETER) { 26 | greeter = process.env.TEST_GREETER 27 | } else if (event.requestContext.greeter) { 28 | greeter = event.requestContext.greeter 29 | } 30 | 31 | res.body = 'esm, ' + greeter + '!' 32 | callback(null, res) 33 | } 34 | -------------------------------------------------------------------------------- /test/lambdas/hello.js: -------------------------------------------------------------------------------- 1 | 2 | exports.handler = function (event, context, callback) { 3 | // console.log('Received event:', JSON.stringify(event, null, 2)); 4 | const res = { 5 | statusCode: 200, 6 | headers: { 7 | 'Content-Type': '*/*' 8 | } 9 | } 10 | let greeter = 'World' 11 | console.log('js hello') 12 | console.log(JSON.stringify(event)) 13 | if (event.greeter && event.greeter !== '') { 14 | greeter = event.greeter 15 | } else if (event.body && event.body !== '') { 16 | const body = JSON.parse(event.body) 17 | if (body.greeter && body.greeter !== '') { 18 | greeter = body.greeter 19 | } 20 | } else if (event.queryStringParameters && event.queryStringParameters.greeter && event.queryStringParameters.greeter !== '') { 21 | greeter = event.queryStringParameters.greeter 22 | } else if (event.multiValueHeaders && event.multiValueHeaders.greeter && event.multiValueHeaders.greeter !== '') { 23 | greeter = event.multiValueHeaders.greeter.join(' and ') 24 | } else if (event.headers && event.headers.greeter && event.headers.greeter !== '') { 25 | greeter = event.headers.greeter 26 | } else if (process.env.TEST_GREETER) { 27 | greeter = process.env.TEST_GREETER 28 | } else if (event.requestContext.greeter) { 29 | greeter = event.requestContext.greeter 30 | } 31 | 32 | res.body = 'Hello, ' + greeter + '!' 33 | callback(null, res) 34 | } 35 | -------------------------------------------------------------------------------- /test/errors.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const { test } = require('tapzero') 5 | 6 | const TestCommon = require('./common/test-common.js') 7 | 8 | test('syntax error', async (t) => { 9 | const common = await TestCommon.create() 10 | 11 | try { 12 | const resp = await common.fetch('/syntax') 13 | t.equal(resp.status, 500, 'statusCode is 500 for /syntax') 14 | 15 | const body = await resp.json() 16 | 17 | t.ok(body, '/syntax returns body') 18 | t.equal(body.message, 'Internal Server Error', 19 | '/syntax returns Interal Server Error') 20 | } finally { 21 | await common.close() 22 | } 23 | }) 24 | 25 | test('runtime error', async (t) => { 26 | const common = await TestCommon.create() 27 | 28 | try { 29 | const resp = await common.fetch('/runtime') 30 | t.equal(resp.status, 500, 'statusCode is 500 for /runtime') 31 | 32 | const body = await resp.json() 33 | 34 | t.ok(body, '/runtime returns http body') 35 | t.equal(body.message, 'Internal Server Error', 36 | '/runtime returns Internal Server Error') 37 | } finally { 38 | await common.close() 39 | } 40 | }) 41 | 42 | test('malformed error', async (t) => { 43 | const common = await TestCommon.create() 44 | 45 | try { 46 | const resp = await common.fetch('/malformed') 47 | t.equal(resp.status, 500, 'expected 500') 48 | 49 | const body = await resp.json() 50 | 51 | t.ok(body, '/malformed returns a body') 52 | t.equal(body.message, 'Lambda returned invalid HTTP result', 53 | 'got invalid http result back') 54 | } finally { 55 | await common.close() 56 | } 57 | }) 58 | 59 | test('dns-poison', async (t) => { 60 | const common = await TestCommon.create() 61 | 62 | try { 63 | const result = await common.fetch('/hello', { 64 | headers: { 65 | host: 'http://dns-poisoning-attack.com' 66 | } 67 | }) 68 | t.equal(result.status, 403, 'bad host header returns 403') 69 | } finally { 70 | await common.close() 71 | } 72 | }) 73 | 74 | test('local website attack', async (t) => { 75 | const common = await TestCommon.create() 76 | 77 | try { 78 | const resp = await common.fetch('/hello', { 79 | headers: { 80 | referer: 'http://example.com' 81 | } 82 | }) 83 | t.equal(resp.status, 403, 'bad referer header returns 403') 84 | } finally { 85 | await common.close() 86 | } 87 | }) 88 | -------------------------------------------------------------------------------- /test/languages.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const { PassThrough } = require('stream') 5 | const { test } = require('tapzero') 6 | 7 | const TestCommon = require('./common/test-common.js') 8 | 9 | test('calling python handler', async (t) => { 10 | const common = await TestCommon.create() 11 | const output = [] 12 | const info = common.lambda.functions.python_lambda 13 | 14 | info.worker.stdout = new PassThrough() 15 | info.worker.stdout.on('data', (data) => { 16 | process.stdout.write(data) 17 | output.push(data) 18 | }) 19 | 20 | try { 21 | const res = await common.fetch('/python') 22 | t.equal(res.status, 200, '/python returns 200') 23 | 24 | const b = await res.text() 25 | t.equal(b, '"Hello from Lambda! (python)"', 26 | 'body from python is correct') 27 | 28 | t.ok(output.join('\n').includes('INFO python hello'), 29 | 'logs from python lambda are correct') 30 | 31 | const eventLine = output.find((line) => { 32 | return line.includes('INFO event') 33 | }) 34 | t.ok(eventLine, 'event is logged') 35 | t.ok(eventLine.includes('\'path\': \'/python\''), 36 | 'event includes path field') 37 | t.ok(eventLine.includes('\'body\': \'\''), 38 | 'event includes body field') 39 | t.ok(eventLine.includes(' \'httpMethod\': \'GET\','), 40 | 'event includes httpMethod field') 41 | } finally { 42 | await common.close() 43 | } 44 | }) 45 | 46 | test('calling go handler', async (t) => { 47 | const common = await TestCommon.create() 48 | const output = [] 49 | const info = common.lambda.functions.go_lambda 50 | 51 | info.worker.stdout = new PassThrough() 52 | info.worker.stdout.on('data', (data) => { 53 | process.stdout.write(data) 54 | output.push(data) 55 | }) 56 | 57 | try { 58 | const res = await common.fetch('/go') 59 | t.equal(res.status, 200, '/go returns 200') 60 | 61 | const b = await res.text() 62 | t.equal(b, 'Hello from Lambda! (go)', 63 | 'body from go is correct') 64 | 65 | t.ok(output.join('\n').includes('INFO hello from lambda'), 66 | 'logs from go lambda are correct') 67 | 68 | const eventLine = output.find((line) => { 69 | return line.includes('INFO event') 70 | }) 71 | t.ok(eventLine, 'event is logged') 72 | t.ok(eventLine.includes('"path":"/go"'), 73 | 'event includes path field') 74 | t.ok(eventLine.includes('"body":"'), 75 | 'event includes body field') 76 | t.ok(eventLine.includes(',"httpMethod":"GET",'), 77 | 'event includes httpMethod field') 78 | } finally { 79 | await common.close() 80 | } 81 | }) 82 | -------------------------------------------------------------------------------- /test/routing.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const path = require('path') 5 | const { test } = require('tapzero') 6 | 7 | const TestCommon = require('./common/test-common.js') 8 | 9 | test('calling / directly', async (t) => { 10 | const common = await TestCommon.create() 11 | 12 | try { 13 | common.lambda.updateWorker({ 14 | runtime: 'nodejs:12.x', 15 | httpPath: '/', 16 | handler: 'echo.handler', 17 | functionName: '_temp_0', 18 | entry: path.join(__dirname, 'lambdas', 'echo.js') 19 | }) 20 | 21 | const r1 = await common.fetch('/') 22 | const t1 = await r1.text() 23 | t.equal(t1, '/ /', '/ api works') 24 | } finally { 25 | await common.close() 26 | } 27 | }) 28 | 29 | test('calling different routes', async (t) => { 30 | const common = await TestCommon.create() 31 | 32 | try { 33 | registerAll([ 34 | '/users', 35 | '/users/{userId}', 36 | '/users/{userId}/status', 37 | '/users/{userId}/teams/{teamId}', 38 | '/nested/hello/{proxy+}', 39 | '/proxy/{proxy+}' 40 | ]) 41 | 42 | const r1 = await common.fetch('/users') 43 | const t1 = await r1.text() 44 | t.equal(t1, '/users /users', 'users api works') 45 | 46 | const r2 = await common.fetch('/users/bob') 47 | const t2 = await r2.text() 48 | t.equal(t2, '/users/{userId} /users/bob', 'users/bob api works') 49 | 50 | const r3 = await common.fetch('/users/bob/foobar') 51 | t.equal(r3.status, 404, 'users/bob/foobar api returns 404') 52 | 53 | const r4 = await common.fetch('/foobar') 54 | t.equal(r4.status, 404, 'foobar api returns 404') 55 | 56 | const r5 = await common.fetch('/users/bob/status') 57 | const t5 = await r5.text() 58 | t.equal( 59 | t5, 60 | '/users/{userId}/status /users/bob/status', 61 | 'users/bob/status api works' 62 | ) 63 | 64 | const r6 = await common.fetch('/users/bob/teams/teamName') 65 | const t6 = await r6.text() 66 | t.equal( 67 | t6, 68 | '/users/{userId}/teams/{teamId} /users/bob/teams/teamName', 69 | 'users/bob/teams/teamName api works' 70 | ) 71 | 72 | const r7 = await common.fetch('/users/bob/teams/teamName/nested') 73 | t.equal(r7.status, 404, 'users/bob/teams/teamName/nested api returns 404') 74 | 75 | const r8 = await common.fetch('/users/bob/teams') 76 | t.equal(r8.status, 404, 'users/bob/teams api returns 404') 77 | 78 | const r9 = await common.fetch('/nested/hello/foo/bar/baz') 79 | const t9 = await r9.text() 80 | t.equal( 81 | t9, 82 | '/nested/hello/{proxy+} /nested/hello/foo/bar/baz', 83 | 'nested/hello/foo/bar/baz api works' 84 | ) 85 | 86 | const r10 = await common.fetch('/proxy/1') 87 | const t10 = await r10.text() 88 | t.equal(t10, '/proxy/{proxy+} /proxy/1', 'proxy/1 api works') 89 | 90 | const r11 = await common.fetch('/proxy') 91 | t.equal(r11.status, 404, 'proxy api returns 404') 92 | 93 | const r12 = await common.fetch('/proxy/') 94 | t.equal(r12.status, 404, 'proxy/ api returns 404') 95 | } finally { 96 | await common.close() 97 | } 98 | 99 | function registerAll (httpPaths) { 100 | let counter = 0 101 | for (const httpPath of httpPaths) { 102 | common.lambda.updateWorker({ 103 | runtime: 'nodejs:12.x', 104 | httpPath: httpPath, 105 | handler: 'echo.handler', 106 | functionName: `_temp_${++counter}`, 107 | entry: path.join(__dirname, 'lambdas', 'echo.js') 108 | }) 109 | } 110 | } 111 | }) 112 | -------------------------------------------------------------------------------- /test/common/test-common.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const { spawnSync } = require('child_process') 5 | const fetch = require('node-fetch').default 6 | const path = require('path') 7 | 8 | const { FakeApiGatewayLambda } = require('../../index.js') 9 | 10 | class TestCommon { 11 | /** 12 | * @typedef {{ 13 | * env?: Record, 14 | * requestContext?: (e: object) => Promise | object 15 | * }} Options 16 | * 17 | * @param {Options} [options] 18 | */ 19 | constructor (options) { 20 | const env = options ? options.env : {} 21 | 22 | /** @type {FakeApiGatewayLambda} */ 23 | this.lambda = new FakeApiGatewayLambda({ 24 | port: 0, 25 | populateRequestContext: options && options.requestContext 26 | }) 27 | 28 | spawnSync('go', ['get'], { 29 | cwd: path.join(__dirname, '..', 'lambdas', 'go') 30 | }) 31 | 32 | this.lambda.updateWorker({ 33 | entry: path.join(__dirname, '..', 'lambdas', 'hello.py'), 34 | env: env, 35 | functionName: 'python_lambda', 36 | httpPath: '/python', 37 | handler: 'lambda_handler.lambda_handler', 38 | runtime: 'python3.9' 39 | }) 40 | 41 | this.lambda.updateWorker({ 42 | entry: path.join(__dirname, '..', 'lambdas', 'go', 'hello.go'), 43 | env: env, 44 | handler: 'hello', 45 | functionName: 'go_lambda', 46 | httpPath: '/go', 47 | runtime: 'go:1.16' 48 | }) 49 | 50 | this.lambda.updateWorker({ 51 | entry: path.join(__dirname, '..', 'lambdas', 'hello.js'), 52 | env: env, 53 | runtime: 'nodejs:12.x', 54 | functionName: 'hello_node_lambda', 55 | handler: 'hello.handler', 56 | httpPath: '/hello' 57 | }) 58 | 59 | this.lambda.updateWorker({ 60 | entry: path.join(__dirname, '..', 'lambdas', 'esm.mjs'), 61 | env: env, 62 | runtime: 'nodejs:12.x', 63 | functionName: 'node_esm_lambda', 64 | handler: 'esm.handler', 65 | httpPath: '/esm' 66 | }) 67 | 68 | this.lambda.updateWorker({ 69 | entry: path.join(__dirname, '..', 'lambdas', 'syntax-error.js'), 70 | env: env, 71 | runtime: 'nodejs:12.x', 72 | functionName: 'syntax_node_lambda', 73 | handler: 'syntax-error.handler', 74 | httpPath: '/syntax' 75 | }) 76 | 77 | this.lambda.updateWorker({ 78 | entry: path.join(__dirname, '..', 'lambdas', 'runtime-error.js'), 79 | env: env, 80 | runtime: 'nodejs:12.x', 81 | functionName: 'runtime_error_node-lambda', 82 | handler: 'runtime-error.handler', 83 | httpPath: '/runtime' 84 | }) 85 | 86 | this.lambda.updateWorker({ 87 | entry: path.join(__dirname, '..', 'lambdas', 'malformed.js'), 88 | env: env, 89 | runtime: 'nodejs:12.x', 90 | functionName: 'malformed_node-lambda', 91 | handler: 'malformed.handler', 92 | httpPath: '/malformed' 93 | }) 94 | } 95 | 96 | /** 97 | * @param {Options} [options] 98 | */ 99 | static async create (options) { 100 | const c = new TestCommon(options) 101 | await c.bootstrap() 102 | return c 103 | } 104 | 105 | /** 106 | * @param {string} url 107 | * @param {import('node-fetch').RequestInit} [init] 108 | * @returns {Promise} 109 | */ 110 | async fetch (url, init) { 111 | return fetch(`http://${this.lambda.hostPort}${url}`, init) 112 | } 113 | 114 | async bootstrap () { 115 | return await this.lambda.bootstrap() 116 | } 117 | 118 | /** 119 | * @returns {Promise} 120 | */ 121 | async close () { 122 | await this.lambda.close() 123 | } 124 | } 125 | 126 | module.exports = TestCommon 127 | -------------------------------------------------------------------------------- /test/esm.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const { PassThrough } = require('stream') 5 | const { test } = require('tapzero') 6 | 7 | const TestCommon = require('./common/test-common.js') 8 | 9 | test('calling /esm with ENV vars 1', async (t) => { 10 | const common = await TestCommon.create({ 11 | env: { TEST_GREETER: 'TEST_ENV_1' } 12 | }) 13 | 14 | try { 15 | const res = await common.fetch('/esm') 16 | t.equal(res.status, 200, '/esm returns 200') 17 | 18 | const b = await res.text() 19 | t.equal(b, 'esm, TEST_ENV_1!', 20 | '/esm can read the environment variable') 21 | } finally { 22 | await common.close() 23 | } 24 | }) 25 | 26 | test('calling /esm with requestContext sync', async (t) => { 27 | const common = await TestCommon.create({ 28 | requestContext: () => { 29 | return { greeter: 'Timothy' } 30 | } 31 | }) 32 | 33 | try { 34 | const res = await common.fetch('/esm') 35 | t.equal(res.status, 200, '/esm returns 200') 36 | 37 | const b = await res.text() 38 | t.equal(b, 'esm, Timothy!', '/esm can read the requestContext') 39 | } finally { 40 | await common.close() 41 | } 42 | }) 43 | 44 | test('calling /esm with requestContext async', async (t) => { 45 | const common = await TestCommon.create({ 46 | requestContext: async () => { 47 | return { greeter: 'Timothy' } 48 | } 49 | }) 50 | 51 | try { 52 | const res = await common.fetch('/esm') 53 | t.equal(res.status, 200, '/esm returns 200') 54 | 55 | const b = await res.text() 56 | t.equal(b, 'esm, Timothy!', '/esm works with async requestContext') 57 | } finally { 58 | await common.close() 59 | } 60 | }) 61 | 62 | test('calling /esm with ENV vars 2', async (t) => { 63 | const common = await TestCommon.create({ 64 | env: { TEST_GREETER: 'TEST_ENV_2' } 65 | }) 66 | 67 | try { 68 | const res = await common.fetch('/esm') 69 | t.equal(res.status, 200, '/esm returns 200') 70 | 71 | const b = await res.text() 72 | t.equal(b, 'esm, TEST_ENV_2!', '/esm can read env variables') 73 | } finally { 74 | await common.close() 75 | } 76 | }) 77 | 78 | test('calling /esm', async (t) => { 79 | const common = await TestCommon.create() 80 | const output = [] 81 | const info = common.lambda.functions.node_esm_lambda 82 | 83 | info.worker.stdout = new PassThrough() 84 | info.worker.stdout.on('data', (data) => output.push(data)) 85 | 86 | try { 87 | const res = await common.fetch('/esm') 88 | t.equal(res.status, 200, '/esm returns 200') 89 | 90 | const b = await res.text() 91 | t.equal(b, 'esm, World!', '/esm returns default payload') 92 | 93 | t.ok(output.join('\n').includes('INFO js esm'), 94 | 'logs from js lambda are correct') 95 | } finally { 96 | await common.close() 97 | } 98 | }) 99 | 100 | test('calling /esm many times', async (t) => { 101 | const common = await TestCommon.create() 102 | 103 | try { 104 | for (let i = 0; i < 5; i++) { 105 | const res = await common.fetch('/esm') 106 | t.equal(res.status, 200, '/esm returns 200 multiple times') 107 | 108 | const b = await res.text() 109 | t.equal(b, 'esm, World!', '/esm returns body multiple times') 110 | } 111 | } finally { 112 | await common.close() 113 | } 114 | }) 115 | 116 | test('calling /esm many times in parallel', async (t) => { 117 | const common = await TestCommon.create() 118 | 119 | try { 120 | // @type {Promise[]} 121 | const tasks = [] 122 | for (let i = 0; i < 5; i++) { 123 | tasks.push(common.fetch('/esm')) 124 | } 125 | 126 | const responses = await Promise.all(tasks) 127 | for (const res of responses) { 128 | t.equal(res.status, 200, '/esm returns 200 in parallel') 129 | 130 | const b = await res.text() 131 | t.equal(b, 'esm, World!', '/esm returns body in parallel') 132 | } 133 | } finally { 134 | await common.close() 135 | } 136 | }) 137 | 138 | test('calling /esm with different args', async (t) => { 139 | const common = await TestCommon.create() 140 | 141 | try { 142 | const res1 = await common.fetch('/esm', { 143 | method: 'POST', 144 | body: JSON.stringify({ greeter: 'James' }) 145 | }) 146 | t.equal(res1.status, 200, '/esm with http body returns 200') 147 | 148 | const b1 = await res1.text() 149 | t.equal(b1, 'esm, James!', '/esm can read http body') 150 | 151 | const res2 = await common.fetch('/esm?greeter=Bob') 152 | t.equal(res2.status, 200, '/esm with query string returns 200') 153 | 154 | const b2 = await res2.text() 155 | t.equal(b2, 'esm, Bob!', '/esm can read querystring') 156 | 157 | const res3 = await common.fetch('/esm', { 158 | headers: [ 159 | ['greeter', 'Charles'], 160 | ['greeter', 'Tim'] 161 | ] 162 | }) 163 | t.equal(res3.status, 200, '/esm with custom headers returns 200') 164 | 165 | const b3 = await res3.text() 166 | t.equal(b3, 'esm, Charles and Tim!', 167 | '/esm can read custom headers') 168 | 169 | const res4 = await common.fetch('/esm', { 170 | headers: { 171 | greeter: 'Alice' 172 | } 173 | }) 174 | t.equal(res4.status, 200, '/esm can read single header') 175 | 176 | const b4 = await res4.text() 177 | t.equal(b4, 'esm, Alice!', '/esm body reads header value') 178 | } finally { 179 | await common.close() 180 | } 181 | }) 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fake-api-gateway-lambda 2 | 3 | This is a testing utility for testing your lambda functions. 4 | 5 | You pass it your lambda function and it will start an emulated 6 | AWS API Gateway on a HTTP port and it will redirect all HTTP 7 | requests to your lambda function using Lambda proxy integration. 8 | 9 | ## Example 10 | 11 | ```js 12 | const { FakeApiGatewayLambda } = require('fake-api-gateway-lambda') 13 | const fetch = require('node-fetch') 14 | const path = require('path') 15 | 16 | async function test() { 17 | const gateway = new FakeApiGatewayLambda({ 18 | port: 0, 19 | env: { 20 | TEST_SETTINGS: '...', 21 | TEST_S3_BUCKET: 'some-test-bucket-NOT-PROD', 22 | TEST_DB_NAME: 'my-app-test' 23 | }, 24 | routes: { 25 | '/hello': path.join( 26 | __dirname, 'lambdas', 'hello-world', 'index.js' 27 | ), 28 | '/contact': path.join( 29 | __dirname, 'lambdas', 'contact', 'index.js' 30 | ) 31 | } 32 | }) 33 | 34 | await gateway.bootstrap() 35 | 36 | const resp = await fetch(`http://${gateway.hostPort}/hello`) 37 | 38 | // Payload of the hello-world lambda response. 39 | const body = await resp.json() 40 | 41 | await gateway.close() 42 | } 43 | 44 | process.on('unhandledRejection', (err) => { throw err }) 45 | test() 46 | ``` 47 | 48 | ## Design 49 | 50 | This testing utility strongly couples AWS lambda & AWS Api gateway. 51 | 52 | This library is only useful if you use AWS Api Gateway with 53 | "Use Lambda Proxy Integration" to expose your Lambda over HTTP. 54 | 55 | ![](lambda.png) 56 | 57 | When writing integration tests for your lambdas you want to be able 58 | to author tests like other applications would use your code which 59 | would be through the AWS API gateway API in either the browser 60 | or another application. 61 | 62 | Lambda has a very unique execution model, we try to emulate a non 63 | trivial amount of lambda. 64 | 65 | The FakeApiGatewayLambda will actually manage a pool of child 66 | processes and will send HTTP req / res to these child processes 67 | so that it can invoke your lambda function, this has similar cold 68 | start semantics to lambda. 69 | 70 | The FakeAPIGatewayLambda will only send one HTTP request to a given 71 | child process at a time ( just like real lambda ), so a given child 72 | process lambda worker can only handle one HTTP request at a time. 73 | 74 | Currently the library has a concurrency limit of 10 hard coded, other 75 | requests will be queued. 76 | 77 | When it comes to invoking & starting your lambda, we pass environment 78 | variables to your lambda by monkey patching `process.env` ; 79 | 80 | ## Recommended testing approach 81 | 82 | Write your lambda to take test parameters from `process.env` ; 83 | For example configuring where it should send emails using SES. 84 | 85 | Then use the FakeApiGatewayLambda to start a HTTP server and send 86 | requests to it like you would do from other applications or from 87 | websites over CORS. 88 | 89 | ## Recommended local development process. 90 | 91 | You can also use this testing library for local development. 92 | 93 | ```js 94 | // bin/dev.js 95 | 96 | async function main() { 97 | const gateway = new FakeApiGatewayLambda({ 98 | port: 8080, 99 | routes: { 100 | '/hello': path.join(__dirname, '..', 'lambdas', 'hello.js') 101 | } 102 | }) 103 | 104 | await gateway.bootstrap(); 105 | console.log('API Gateway running on localhost:8000'); 106 | } 107 | 108 | process.on('unhandledRejection', (err) => { throw err }) 109 | main(); 110 | ``` 111 | 112 | Just add a simple script to your codebase and run it from `npm start` 113 | to start a HTTP server that sends HTTP requests to your lambdas. 114 | 115 | You can tell use environment variables to configure this for 116 | local development, aka tell it what resources to use 117 | ( local, dev, staging, prod) etc. 118 | 119 | ## Docs : 120 | 121 | ### `const server = new FakeApiGatewayLambda(opts)` 122 | 123 | Creates a fake api gateway server that routes HTTP traffic 124 | to your lambdas. 125 | 126 | - `opts.port` ; defaults to 0 127 | - `opts.routes` ; An object where the key is a route prefix and 128 | the value is the absolute file path to your lambda function. 129 | - `opts.env` ; An optional env object, the lambdas will have these 130 | env variables set in `process.env` ; this allows you to pass test 131 | paramaters to the lambdas. 132 | - `opts.enableCors` ; An optional boolean. If set to true the HTTP 133 | server will respond with CORS enabled headers. 134 | - `opts.populateRequextContext` ; An optional function that creates 135 | a request context object that will be passed into every lambda 136 | invocation for this api-gateway. This function takes the lambda 137 | event as the first argument. This is useful for populating 138 | request context, like for example the cognito user pool information 139 | if your lambda uses AWS amplify. This function can be sync or 140 | async, aka return a `requestContext` object or return a promise 141 | that resolves to a `requestContext` object. 142 | - `opts.silent` ; An optional boolean. If set the stdout/stderr 143 | output of the lambda process will be supressed. This means you 144 | can add `console.log` to your lambda and have these be hidden 145 | in tests if they are too noisy. 146 | 147 | Your lambda function is spawned as a child process by the ApiGateway 148 | server. 149 | 150 | ### `await server.bootstrap()` 151 | 152 | Starts the server. 153 | 154 | After bootstrap returns you can read `server.hostPort` to get the 155 | actual listening port of the server. 156 | 157 | ### `await server.close()` 158 | 159 | Shuts down the server. 160 | 161 | ## install 162 | 163 | ``` 164 | % npm install fake-api-gateway-lambda 165 | ``` 166 | 167 | ## MIT Licensed 168 | 169 | -------------------------------------------------------------------------------- /_types/node-fetch/index.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 3.0 2 | /// 3 | 4 | import { Agent } from "http"; 5 | import { URLSearchParams } from "url"; 6 | 7 | export class Request extends Body { 8 | constructor(input: string | { href: string } | Request, init?: RequestInit); 9 | clone(): Request; 10 | context: RequestContext; 11 | headers: Headers; 12 | method: string; 13 | redirect: RequestRedirect; 14 | referrer: string; 15 | url: string; 16 | 17 | // node-fetch extensions to the whatwg/fetch spec 18 | agent?: Agent; 19 | compress: boolean; 20 | counter: number; 21 | follow: number; 22 | hostname: string; 23 | port?: number; 24 | protocol: string; 25 | size: number; 26 | timeout: number; 27 | } 28 | 29 | export interface RequestInit { 30 | // whatwg/fetch standard options 31 | body?: BodyInit; 32 | headers?: HeadersInit; 33 | method?: string; 34 | redirect?: RequestRedirect; 35 | 36 | // node-fetch extensions 37 | agent?: Agent; // =null http.Agent instance, allows custom proxy, certificate etc. 38 | compress?: boolean; // =true support gzip/deflate content encoding. false to disable 39 | follow?: number; // =20 maximum redirect count. 0 to not follow redirect 40 | size?: number; // =0 maximum response body size in bytes. 0 to disable 41 | timeout?: number; // =0 req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies) 42 | 43 | // node-fetch does not support mode, cache or credentials options 44 | } 45 | 46 | export type RequestContext = 47 | "audio" 48 | | "beacon" 49 | | "cspreport" 50 | | "download" 51 | | "embed" 52 | | "eventsource" 53 | | "favicon" 54 | | "fetch" 55 | | "font" 56 | | "form" 57 | | "frame" 58 | | "hyperlink" 59 | | "iframe" 60 | | "image" 61 | | "imageset" 62 | | "import" 63 | | "internal" 64 | | "location" 65 | | "manifest" 66 | | "object" 67 | | "ping" 68 | | "plugin" 69 | | "prefetch" 70 | | "script" 71 | | "serviceworker" 72 | | "sharedworker" 73 | | "style" 74 | | "subresource" 75 | | "track" 76 | | "video" 77 | | "worker" 78 | | "xmlhttprequest" 79 | | "xslt"; 80 | export type RequestMode = "cors" | "no-cors" | "same-origin"; 81 | export type RequestRedirect = "error" | "follow" | "manual"; 82 | export type RequestCredentials = "omit" | "include" | "same-origin"; 83 | 84 | export type RequestCache = 85 | "default" 86 | | "force-cache" 87 | | "no-cache" 88 | | "no-store" 89 | | "only-if-cached" 90 | | "reload"; 91 | 92 | export class Headers implements Iterable<[string, string]> { 93 | constructor(init?: HeadersInit); 94 | forEach(callback: (value: string, name: string) => void): void; 95 | append(name: string, value: string): void; 96 | delete(name: string): void; 97 | get(name: string): string | null; 98 | getAll(name: string): string[]; 99 | has(name: string): boolean; 100 | raw(): { [k: string]: string[] }; 101 | set(name: string, value: string): void; 102 | 103 | // Iterator methods 104 | entries(): Iterator<[string, string]>; 105 | keys(): Iterator; 106 | values(): Iterator<[string]>; 107 | [Symbol.iterator](): Iterator<[string, string]>; 108 | } 109 | 110 | type BlobPart = ArrayBuffer | ArrayBufferView | Blob | string; 111 | 112 | interface BlobOptions { 113 | type?: string; 114 | endings?: "transparent" | "native"; 115 | } 116 | 117 | export class Blob { 118 | constructor(blobParts?: BlobPart[], options?: BlobOptions); 119 | readonly type: string; 120 | readonly size: number; 121 | slice(start?: number, end?: number): Blob; 122 | } 123 | 124 | export class Body { 125 | constructor(body?: unknown, opts?: { size?: number; timeout?: number }); 126 | arrayBuffer(): Promise; 127 | blob(): Promise; 128 | body: NodeJS.ReadableStream; 129 | bodyUsed: boolean; 130 | buffer(): Promise; 131 | json(): Promise; 132 | text(): Promise; 133 | textConverted(): Promise; 134 | } 135 | 136 | export class FetchError extends Error { 137 | name: "FetchError"; 138 | constructor(message: string, type: string, systemError?: string); 139 | type: string; 140 | code?: string; 141 | errno?: string; 142 | } 143 | 144 | export class Response extends Body { 145 | constructor(body?: BodyInit, init?: ResponseInit); 146 | static error(): Response; 147 | static redirect(url: string, status: number): Response; 148 | clone(): Response; 149 | headers: Headers; 150 | ok: boolean; 151 | size: number; 152 | status: number; 153 | statusText: string; 154 | timeout: number; 155 | type: ResponseType; 156 | url: string; 157 | } 158 | 159 | export type ResponseType = 160 | "basic" 161 | | "cors" 162 | | "default" 163 | | "error" 164 | | "opaque" 165 | | "opaqueredirect"; 166 | 167 | export interface ResponseInit { 168 | headers?: HeadersInit; 169 | status: number; 170 | statusText?: string; 171 | } 172 | 173 | export type HeadersInit = Headers | string[][] | { [key: string]: string }; 174 | // HeaderInit is exported to support backwards compatibility. See PR #34382 175 | export type HeaderInit = HeadersInit; 176 | export type BodyInit = 177 | ArrayBuffer 178 | | ArrayBufferView 179 | | NodeJS.ReadableStream 180 | | string 181 | | URLSearchParams; 182 | export type RequestInfo = string | Request; 183 | 184 | declare function fetch( 185 | url: string | Request, 186 | init?: RequestInit 187 | ): Promise; 188 | 189 | declare namespace fetch { 190 | function isRedirect(code: number): boolean; 191 | } 192 | 193 | export default fetch; 194 | -------------------------------------------------------------------------------- /workers/js-worker-txt.js: -------------------------------------------------------------------------------- 1 | module.exports = ` 2 | 3 | // @ts-check 4 | 'use strict' 5 | 6 | /** 7 | * This is the worker child process that imports the lambda 8 | * user code. 9 | * 10 | * This needs to do a bunch of "simulation" work to make 11 | * it appear like a real AWS lambda. 12 | * 13 | * https://github.com/ashiina/lambda-local 14 | * https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html 15 | */ 16 | 17 | /** 18 | @typedef {{ 19 | isBase64Encoded: boolean; 20 | statusCode: number; 21 | headers?: Record; 22 | multiValueHeaders?: Record; 23 | body: string; 24 | }} LambdaResult 25 | @typedef {{ 26 | handler( 27 | event: object, 28 | ctx: object, 29 | cb: (err: Error, result?: LambdaResult) => void 30 | ): Promise | null; 31 | }} LambdaFunction 32 | */ 33 | 34 | process.on('unhandledRejection', (err) => { 35 | throw err 36 | }) 37 | 38 | class LambdaWorker { 39 | constructor (entry, handler) { 40 | this.lambdaFunctionPromise = dynamicLambdaRequire(entry) 41 | this.handler = handler 42 | } 43 | 44 | /** 45 | * @param {{ 46 | * message: string, 47 | * id: string, 48 | * eventObject: Record 49 | * }} msg 50 | * @returns {void} 51 | */ 52 | async handleMessage (msg) { 53 | if (typeof msg !== 'object' || Object.is(msg, null)) { 54 | bail('bad data type from parent process: handleMessage') 55 | return 56 | } 57 | 58 | const objMsg = msg 59 | const messageType = objMsg.message 60 | 61 | if (messageType === 'event') { 62 | const id = objMsg.id 63 | if (typeof id !== 'string') { 64 | bail('missing id from parent process: event') 65 | return 66 | } 67 | 68 | const eventObject = objMsg.eventObject 69 | if ( 70 | typeof eventObject !== 'object' || 71 | eventObject === null 72 | ) { 73 | bail('missing eventObject from parent process: event') 74 | return 75 | } 76 | 77 | await this.invokeLambda(id, eventObject, objMsg.raw) 78 | } else { 79 | bail('bad data type from parent process: unknown') 80 | } 81 | } 82 | 83 | /** 84 | * @param {string} id 85 | * @param {Record} eventObject 86 | * @returns {void} 87 | */ 88 | async invokeLambda (id, eventObject, raw) { 89 | /** 90 | * @raynos TODO: We have to populate the lambda eventObject 91 | * here and we have not done so at all. 92 | */ 93 | 94 | /** 95 | * @raynos TODO: We have to pretend to be lambda here. 96 | * We need to set a bunch of global environment variables. 97 | * 98 | * There are other lambda emulation modules that have 99 | * reference examples of how to "pretend" to be lambda 100 | * that we can borrow implementations from. 101 | */ 102 | 103 | const lambdaFunction = await this.lambdaFunctionPromise 104 | const fn = lambdaFunction[this.handler] 105 | const maybePromise = fn(eventObject, {}, (err, result) => { 106 | if (!result) { 107 | console.log('Callback error happened', err) 108 | this.sendError(id, err) 109 | return 110 | } 111 | 112 | this.sendResult(id, result, raw) 113 | }) 114 | 115 | if (maybePromise) { 116 | return maybePromise.then((result) => { 117 | this.sendResult(id, result, raw) 118 | }, (/** @type {Error} */ err) => { 119 | console.log('promise rejection', err) 120 | this.sendError(id, err) 121 | }) 122 | } 123 | } 124 | 125 | /** 126 | * @param {string} id 127 | * @param {Error} err 128 | * @returns {void} 129 | */ 130 | sendError (id, err) { 131 | /** 132 | * @raynos TODO: We should identify what AWS lambda does here 133 | * in co-ordination with AWS API Gateway and return that 134 | * instead. 135 | */ 136 | this.sendResult(id, { 137 | isBase64Encoded: false, 138 | statusCode: 500, 139 | headers: {}, 140 | body: 'fake-api-gateway-lambda: ' + (err && (err.message || err)), 141 | multiValueHeaders: {} 142 | }) 143 | } 144 | 145 | /** 146 | * @param {string} id 147 | * @param {LambdaResult} result 148 | * @returns {void} 149 | */ 150 | sendResult (id, result, raw) { 151 | const msg = JSON.stringify({ 152 | message: 'result', 153 | id, 154 | result: raw ? result : { 155 | isBase64Encoded: result.isBase64Encoded || false, 156 | statusCode: result.statusCode, 157 | headers: result.headers || {}, 158 | body: result.body || '', 159 | multiValueHeaders: result.multiValueHeaders 160 | }, 161 | memory: process.memoryUsage().heapUsed 162 | }) 163 | 164 | const line = '__FAKE_LAMBDA_START__ ' + msg + ' __FAKE_LAMBDA_END__' 165 | process.stdout.write('\\n' + line + '\\n') 166 | } 167 | } 168 | 169 | /** 170 | * @param {string} msg 171 | * @returns {void} 172 | */ 173 | function bail (msg) { 174 | process.stderr.write( 175 | 'fake-api-gateway-lambda: ' + 176 | 'The lambda process has to exit because: ' + 177 | msg + '\\n', 178 | () => { 179 | process.exit(1) 180 | } 181 | ) 182 | } 183 | 184 | /** 185 | * @param {string} fileName 186 | * @returns {LambdaFunction} 187 | */ 188 | async function dynamicLambdaRequire (fileName) { 189 | try { 190 | return /** @type {LambdaFunction} */ (require(fileName)) 191 | } catch (err) { 192 | if (err.code === 'ERR_REQUIRE_ESM') { 193 | return await import(fileName) 194 | } 195 | 196 | if (err.name === 'SyntaxError') { 197 | try { 198 | return await import(fileName) 199 | } catch (err2) { 200 | throw err2 201 | } 202 | } 203 | 204 | throw err 205 | } 206 | } 207 | 208 | function main () { 209 | const worker = new LambdaWorker( 210 | process.argv[2], 211 | process.argv[3] 212 | ) 213 | 214 | let stdinData = '' 215 | 216 | process.stdin.on('data', (bytes) => { 217 | const str = bytes.toString('utf8') 218 | stdinData += str 219 | 220 | const lines = stdinData.split('\\n') 221 | for (let i = 0; i < lines.length - 1; i++) { 222 | const line = lines[i] 223 | const msg = JSON.parse(line) 224 | worker.handleMessage(msg) 225 | } 226 | 227 | stdinData = lines[lines.length - 1] 228 | }) 229 | } 230 | 231 | if (module === require.main) { 232 | main() 233 | } 234 | ` 235 | -------------------------------------------------------------------------------- /test/workers.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const { PassThrough } = require('stream') 5 | const { test } = require('tapzero') 6 | const path = require('path') 7 | 8 | const TestCommon = require('./common/test-common.js') 9 | 10 | test('calling /hello with ENV vars 1', async (t) => { 11 | const common = await TestCommon.create({ 12 | env: { TEST_GREETER: 'TEST_ENV_1' } 13 | }) 14 | 15 | try { 16 | const res = await common.fetch('/hello') 17 | t.equal(res.status, 200, '/hello returns 200') 18 | 19 | const b = await res.text() 20 | t.equal(b, 'Hello, TEST_ENV_1!', 21 | '/hello can read the environment variable') 22 | } finally { 23 | await common.close() 24 | } 25 | }) 26 | 27 | test('calling /hello with requestContext sync', async (t) => { 28 | const common = await TestCommon.create({ 29 | requestContext: () => { 30 | return { greeter: 'Timothy' } 31 | } 32 | }) 33 | 34 | try { 35 | const res = await common.fetch('/hello') 36 | t.equal(res.status, 200, '/hello returns 200') 37 | 38 | const b = await res.text() 39 | t.equal(b, 'Hello, Timothy!', '/hello can read the requestContext') 40 | } finally { 41 | await common.close() 42 | } 43 | }) 44 | 45 | test('calling /hello with requestContext async', async (t) => { 46 | const common = await TestCommon.create({ 47 | requestContext: async () => { 48 | return { greeter: 'Timothy' } 49 | } 50 | }) 51 | 52 | try { 53 | const res = await common.fetch('/hello') 54 | t.equal(res.status, 200, '/hello returns 200') 55 | 56 | const b = await res.text() 57 | t.equal(b, 'Hello, Timothy!', '/hello works with async requestContext') 58 | } finally { 59 | await common.close() 60 | } 61 | }) 62 | 63 | test('calling /hello with ENV vars 2', async (t) => { 64 | const common = await TestCommon.create({ 65 | env: { TEST_GREETER: 'TEST_ENV_2' } 66 | }) 67 | 68 | try { 69 | const res = await common.fetch('/hello') 70 | t.equal(res.status, 200, '/hello returns 200') 71 | 72 | const b = await res.text() 73 | t.equal(b, 'Hello, TEST_ENV_2!', '/hello can read env variables') 74 | } finally { 75 | await common.close() 76 | } 77 | }) 78 | 79 | test('calling /hello', async (t) => { 80 | const common = await TestCommon.create() 81 | const output = [] 82 | const info = common.lambda.functions.hello_node_lambda 83 | 84 | info.worker.stdout = new PassThrough() 85 | info.worker.stdout.on('data', (data) => output.push(data)) 86 | 87 | try { 88 | const res = await common.fetch('/hello') 89 | t.equal(res.status, 200, '/hello returns 200') 90 | 91 | const b = await res.text() 92 | t.equal(b, 'Hello, World!', '/hello returns default payload') 93 | 94 | t.ok(output.join('\n').includes('INFO js hello'), 95 | 'logs from js lambda are correct') 96 | } finally { 97 | await common.close() 98 | } 99 | }) 100 | 101 | test('calling /hello many times', async (t) => { 102 | const common = await TestCommon.create() 103 | 104 | try { 105 | for (let i = 0; i < 5; i++) { 106 | const res = await common.fetch('/hello') 107 | t.equal(res.status, 200, '/hello returns 200 multiple times') 108 | 109 | const b = await res.text() 110 | t.equal(b, 'Hello, World!', '/hello returns body multiple times') 111 | } 112 | } finally { 113 | await common.close() 114 | } 115 | }) 116 | 117 | test('calling /hello many times in parallel', async (t) => { 118 | const common = await TestCommon.create() 119 | 120 | try { 121 | // @type {Promise[]} 122 | const tasks = [] 123 | for (let i = 0; i < 5; i++) { 124 | tasks.push(common.fetch('/hello')) 125 | } 126 | 127 | const responses = await Promise.all(tasks) 128 | for (const res of responses) { 129 | t.equal(res.status, 200, '/hello returns 200 in parallel') 130 | 131 | const b = await res.text() 132 | t.equal(b, 'Hello, World!', '/hello returns body in parallel') 133 | } 134 | } finally { 135 | await common.close() 136 | } 137 | }) 138 | 139 | test('calling /hello with different args', async (t) => { 140 | const common = await TestCommon.create() 141 | 142 | try { 143 | const res1 = await common.fetch('/hello', { 144 | method: 'POST', 145 | body: JSON.stringify({ greeter: 'James' }) 146 | }) 147 | t.equal(res1.status, 200, '/hello with http body returns 200') 148 | 149 | const b1 = await res1.text() 150 | t.equal(b1, 'Hello, James!', '/hello can read http body') 151 | 152 | const res2 = await common.fetch('/hello?greeter=Bob') 153 | t.equal(res2.status, 200, '/hello with query string returns 200') 154 | 155 | const b2 = await res2.text() 156 | t.equal(b2, 'Hello, Bob!', '/hello can read querystring') 157 | 158 | const res3 = await common.fetch('/hello', { 159 | headers: [ 160 | ['greeter', 'Charles'], 161 | ['greeter', 'Tim'] 162 | ] 163 | }) 164 | t.equal(res3.status, 200, '/hello with custom headers returns 200') 165 | 166 | const b3 = await res3.text() 167 | t.equal(b3, 'Hello, Charles and Tim!', 168 | '/hello can read custom headers') 169 | 170 | const res4 = await common.fetch('/hello', { 171 | headers: { 172 | greeter: 'Alice' 173 | } 174 | }) 175 | t.equal(res4.status, 200, '/hello can read single header') 176 | 177 | const b4 = await res4.text() 178 | t.equal(b4, 'Hello, Alice!', '/hello body reads header value') 179 | } finally { 180 | await common.close() 181 | } 182 | }) 183 | 184 | test('calling not found endpoint', async (t) => { 185 | const common = await TestCommon.create() 186 | 187 | try { 188 | const res = await common.fetch('/foo') 189 | t.equal(res.status, 404, 'random URL returns 403 instead of 404') 190 | 191 | const b = await res.text() 192 | t.equal( 193 | b, 194 | '{"message":"NotFound: The local server does not have this URL path"}', 195 | '403 body is correct' 196 | ) 197 | } finally { 198 | await common.close() 199 | } 200 | }) 201 | 202 | test('adding a lambda worker later', async (t) => { 203 | const common = await TestCommon.create() 204 | 205 | try { 206 | common.lambda.updateWorker({ 207 | httpPath: '/foo', 208 | runtime: 'nodejs:12.x', 209 | handler: 'hello.handler', 210 | functionName: 'hello_node_lambda', 211 | entry: path.join(__dirname, 'lambdas', 'hello.js') 212 | }) 213 | const res = await common.fetch('/foo') 214 | t.equal(res.status, 200, '/foo returns 200 after updateWorker()') 215 | } finally { 216 | await common.close() 217 | } 218 | }) 219 | 220 | test('calling changePort', async (t) => { 221 | const common = await TestCommon.create() 222 | 223 | try { 224 | common.lambda.updateWorker({ 225 | httpPath: '/foo', 226 | runtime: 'nodejs:12.x', 227 | handler: 'hello.handler', 228 | functionName: 'hello_node_lambda', 229 | entry: path.join(__dirname, 'lambdas', 'hello.js') 230 | }) 231 | 232 | const oldHostPort = common.lambda.hostPort 233 | await common.lambda.changePort(0) 234 | 235 | t.notEqual(oldHostPort, common.lambda.hostPort, 236 | 'the hostPort has changed') 237 | 238 | const res = await common.fetch('/foo') 239 | t.equal(res.status, 200, '/foo works with new port') 240 | } finally { 241 | await common.close() 242 | } 243 | }) 244 | 245 | test('Calling a raw lambda', async (t) => { 246 | const common = await TestCommon.create() 247 | 248 | try { 249 | const url = '/___FAKE_API_GATEWAY_LAMBDA___RAW___?' + 250 | 'functionName=malformed_node-lambda' 251 | 252 | const res = await common.fetch(url, { 253 | method: 'POST', 254 | body: '{}' 255 | }) 256 | t.equal(res.status, 200, '/raw invoke returns 200') 257 | 258 | const b = await res.text() 259 | t.equal(b, '"Invalid Non HTTP response string"', 260 | 'body from raw lambda is correct') 261 | } finally { 262 | await common.close() 263 | } 264 | }) 265 | -------------------------------------------------------------------------------- /_types/pre-bundled__tape/index.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 3.0 2 | 3 | export = tape; 4 | 5 | /** 6 | * Create a new test with an optional name string and optional opts object. 7 | * cb(t) fires with the new test object t once all preceeding tests have finished. 8 | * Tests execute serially. 9 | */ 10 | declare function tape(name: string | tape.TestOptions, cb: tape.TestCase): void; 11 | declare function tape(name: string, opts: tape.TestOptions, cb: tape.TestCase): void; 12 | declare function tape(cb: tape.TestCase): void; 13 | 14 | declare namespace tape { 15 | /* eslint-disable-next-line @typescript-eslint/no-invalid-void-type */ 16 | type TestCase = (test: Test) => void | Promise; 17 | 18 | /** 19 | * Available opts options for the tape function. 20 | */ 21 | interface TestOptions { 22 | skip?: boolean; // See tape.skip. 23 | timeout?: number; // Set a timeout for the test, after which it will fail. See tape.timeoutAfter. 24 | } 25 | 26 | /** 27 | * Options for the createStream function. 28 | */ 29 | interface StreamOptions { 30 | objectMode?: boolean; 31 | } 32 | 33 | /** 34 | * Generate a new test that will be skipped over. 35 | */ 36 | function skip(name: string | TestOptions, cb: TestCase): void; 37 | function skip(name: string | TestCase): void; 38 | function skip(name: string, opts: TestOptions, cb: TestCase): void; 39 | 40 | /** 41 | * The onFinish hook will get invoked when ALL tape tests have finished right before tape is about to print the test summary. 42 | */ 43 | function onFinish(cb: () => void): void; 44 | 45 | /** 46 | * Like test(name?, opts?, cb) except if you use .only this is the only test case that will run for the entire process, all other test cases using tape will be ignored. 47 | */ 48 | function only(name: string | TestOptions, cb: TestCase): void; 49 | function only(name: string, opts: TestOptions, cb: TestCase): void; 50 | function only(cb: TestCase): void; 51 | 52 | /** 53 | * Create a new test harness instance, which is a function like test(), but with a new pending stack and test state. 54 | */ 55 | function createHarness(): typeof tape; 56 | /** 57 | * Create a stream of output, bypassing the default output stream that writes messages to console.log(). 58 | * By default stream will be a text stream of TAP output, but you can get an object stream instead by setting opts.objectMode to true. 59 | */ 60 | function createStream(opts?: StreamOptions): NodeJS.ReadableStream; 61 | 62 | interface Test { 63 | /** 64 | * Create a subtest with a new test handle st from cb(st) inside the current test. 65 | * cb(st) will only fire when t finishes. 66 | * Additional tests queued up after t will not be run until all subtests finish. 67 | */ 68 | test(name: string, cb: TestCase): void; 69 | test(name: string, opts: TestOptions, cb: TestCase): void; 70 | 71 | /** 72 | * Declare that n assertions should be run. end() will be called automatically after the nth assertion. 73 | * If there are any more assertions after the nth, or after end() is called, they will generate errors. 74 | */ 75 | plan(n: number): void; 76 | 77 | /** 78 | * Declare the end of a test explicitly. 79 | * If err is passed in t.end will assert that it is falsey. 80 | */ 81 | end(err?: unknown): void; 82 | 83 | /** 84 | * Generate a failing assertion with a message msg. 85 | */ 86 | fail(msg?: string): void; 87 | 88 | /** 89 | * Generate a passing assertion with a message msg. 90 | */ 91 | pass(msg?: string): void; 92 | 93 | /** 94 | * Automatically timeout the test after X ms. 95 | */ 96 | timeoutAfter(ms: number): void; 97 | 98 | /** 99 | * Generate an assertion that will be skipped over. 100 | */ 101 | skip(msg?: string): void; 102 | 103 | /** 104 | * Assert that value is truthy with an optional description message msg. 105 | */ 106 | ok(value: unknown, msg?: string): void; 107 | true(value: unknown, msg?: string): void; 108 | assert(value: unknown, msg?: string): void; 109 | 110 | /** 111 | * Assert that value is falsy with an optional description message msg. 112 | */ 113 | notOk(value: unknown, msg?: string): void; 114 | false(value: unknown, msg?: string): void; 115 | notok(value: unknown, msg?: string): void; 116 | 117 | /** 118 | * Assert that err is falsy. 119 | * If err is non-falsy, use its err.message as the description message. 120 | */ 121 | error(err: unknown, msg?: string): void; 122 | ifError(err: unknown, msg?: string): void; 123 | ifErr(err: unknown, msg?: string): void; 124 | iferror(err: unknown, msg?: string): void; 125 | 126 | /** 127 | * Assert that a === b with an optional description msg. 128 | */ 129 | equal(actual: T, expected: T, msg?: string): void; 130 | equals(actual: T, expected: T, msg?: string): void; 131 | isEqual(actual: T, expected: T, msg?: string): void; 132 | is(actual: T, expected: T, msg?: string): void; 133 | strictEqual(actual: T, expected: T, msg?: string): void; 134 | strictEquals(actual: T, expected: T, msg?: string): void; 135 | 136 | /** 137 | * Assert that a !== b with an optional description msg. 138 | */ 139 | notEqual(actual: unknown, expected: unknown, msg?: string): void; 140 | notEquals(actual: unknown, expected: unknown, msg?: string): void; 141 | notStrictEqual(actual: unknown, expected: unknown, msg?: string): void; 142 | notStrictEquals(actual: unknown, expected: unknown, msg?: string): void; 143 | isNotEqual(actual: unknown, expected: unknown, msg?: string): void; 144 | isNot(actual: unknown, expected: unknown, msg?: string): void; 145 | not(actual: unknown, expected: unknown, msg?: string): void; 146 | doesNotEqual(actual: unknown, expected: unknown, msg?: string): void; 147 | isInequal(actual: unknown, expected: unknown, msg?: string): void; 148 | 149 | /** 150 | * Assert that a and b have the same structure and nested values using node's deepEqual() algorithm with strict comparisons (===) on leaf nodes and an optional description msg. 151 | */ 152 | deepEqual(actual: T, expected: T, msg?: string): void; 153 | deepEquals(actual: T, expected: T, msg?: string): void; 154 | isEquivalent(actual: T, expected: T, msg?: string): void; 155 | same(actual: T, expected: T, msg?: string): void; 156 | 157 | /** 158 | * Assert that a and b do not have the same structure and nested values using node's deepEqual() algorithm with strict comparisons (===) on leaf nodes and an optional description msg. 159 | */ 160 | notDeepEqual(actual: unknown, expected: unknown, msg?: string): void; 161 | notEquivalent(actual: unknown, expected: unknown, msg?: string): void; 162 | notDeeply(actual: unknown, expected: unknown, msg?: string): void; 163 | notSame(actual: unknown, expected: unknown, msg?: string): void; 164 | isNotDeepEqual(actual: unknown, expected: unknown, msg?: string): void; 165 | isNotDeeply(actual: unknown, expected: unknown, msg?: string): void; 166 | isNotEquivalent(actual: unknown, expected: unknown, msg?: string): void; 167 | isInequivalent(actual: unknown, expected: unknown, msg?: string): void; 168 | 169 | /** 170 | * Assert that a and b have the same structure and nested values using node's deepEqual() algorithm with loose comparisons (==) on leaf nodes and an optional description msg. 171 | */ 172 | deepLooseEqual(actual: unknown, expected: unknown, msg?: string): void; 173 | looseEqual(actual: unknown, expected: unknown, msg?: string): void; 174 | looseEquals(actual: unknown, expected: unknown, msg?: string): void; 175 | 176 | /** 177 | * Assert that a and b do not have the same structure and nested values using node's deepEqual() algorithm with loose comparisons (==) on leaf nodes and an optional description msg. 178 | */ 179 | notDeepLooseEqual(actual: unknown, expected: unknown, msg?: string): void; 180 | notLooseEqual(actual: unknown, expected: unknown, msg?: string): void; 181 | notLooseEquals(actual: unknown, expected: unknown, msg?: string): void; 182 | 183 | /** 184 | * Assert that the function call fn() throws an exception. 185 | * expected, if present, must be a RegExp or Function, which is used to test the exception object. 186 | */ 187 | throws(fn: () => void, msg?: string): void; 188 | throws(fn: () => void, exceptionExpected: RegExp | typeof Error, msg?: string): void; 189 | 190 | /** 191 | * Assert that the function call fn() does not throw an exception. 192 | */ 193 | doesNotThrow(fn: () => void, msg?: string): void; 194 | doesNotThrow(fn: () => void, exceptionExpected: RegExp | typeof Error, msg?: string): void; 195 | 196 | /** 197 | * Print a message without breaking the tap output. 198 | * (Useful when using e.g. tap-colorize where output is buffered & console.log will print in incorrect order vis-a-vis tap output.) 199 | */ 200 | comment(msg: string): void; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /child-process-worker.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const childProcess = require('child_process') 5 | const assert = require('assert') 6 | const path = require('path') 7 | const fs = require('fs') 8 | const os = require('os') 9 | 10 | const tmp = os.tmpdir() 11 | 12 | const JS_WORKER_TXT = require('./workers/js-worker-txt.js') 13 | const PY_WORKER_TXT = require('./workers/py-worker-txt.js') 14 | 15 | const GO_WORKER_WINDOWS_TXT = require('./workers/go-worker-windows-txt.js') 16 | const GO_WORKER_POSIX_TXT = require('./workers/go-worker-posix-txt.js') 17 | 18 | const WORKER_PATH = `${tmp}/fake-api-gateway-lambda/worker.js` 19 | const PYTHON_WORKER_PATH = `${tmp}/fake-api-gateway-lambda/worker.py` 20 | const GO_WORKER_POSIX_PATH = `${tmp}/fake-api-gateway-lambda/worker-posix.go` 21 | const GO_WORKER_WINDOWS_PATH = `${tmp}/fake-api-gateway-lambda/worker-windows.go` 22 | 23 | const isWindows = os.platform() === 'win32' 24 | 25 | try { 26 | fs.mkdirSync(path.dirname(WORKER_PATH), { recursive: true }) 27 | fs.writeFileSync(WORKER_PATH, JS_WORKER_TXT) 28 | 29 | if (isWindows) { 30 | fs.mkdirSync(path.dirname(GO_WORKER_WINDOWS_PATH), { recursive: true }) 31 | fs.writeFileSync(GO_WORKER_WINDOWS_PATH, GO_WORKER_WINDOWS_TXT) 32 | } else { 33 | fs.mkdirSync(path.dirname(GO_WORKER_POSIX_PATH), { recursive: true }) 34 | fs.writeFileSync(GO_WORKER_POSIX_PATH, GO_WORKER_POSIX_TXT) 35 | } 36 | 37 | fs.mkdirSync(path.dirname(PYTHON_WORKER_PATH), { recursive: true }) 38 | fs.writeFileSync(PYTHON_WORKER_PATH, PY_WORKER_TXT) 39 | } catch (err) { 40 | console.error('Could not copy worker.{js,py,go} into tmp', err) 41 | } 42 | 43 | class ChildProcessWorker { 44 | /** 45 | * @param {{ 46 | * stdout?: object, 47 | * stderr?: object, 48 | * entry: string, 49 | * handler: string, 50 | * env: object, 51 | * runtime: string 52 | * }} options 53 | */ 54 | constructor (options) { 55 | assert(options.handler, 'options.handler required') 56 | assert(options.runtime, 'options.runtime required') 57 | assert(options.entry, 'options.entry required') 58 | 59 | this.responses = {} 60 | this.procs = [] 61 | this.stdout = options.stdout || process.stdout 62 | this.stderr = options.stderr || process.stderr 63 | 64 | this.runtime = options.runtime 65 | this.entry = options.entry 66 | this.handler = options.handler 67 | this.env = options.env 68 | // this.options = options 69 | } 70 | 71 | logLine (output, line, type) { 72 | if (line === '') { 73 | return 74 | } 75 | 76 | const msg = `${new Date().toISOString()} ${this.latestId} ${type} ` + line 77 | output.write(msg) 78 | } 79 | 80 | /** 81 | * @param {{ 82 | * stdout: import('stream').Readable, 83 | * output: import('stream').Writable, 84 | * handleMessage: (o: object) => void 85 | * }} opts 86 | */ 87 | parseStdout (opts) { 88 | const { stdout, output, handleMessage } = opts 89 | 90 | let remainder = '' 91 | const START_LEN = '__FAKE_LAMBDA_START__'.length 92 | const END_LEN = '__FAKE_LAMBDA_END__'.length 93 | 94 | stdout.on('data', (bytes) => { 95 | const str = remainder + bytes.toString() 96 | remainder = '' 97 | 98 | if (str.indexOf('\n') === -1) { 99 | return this.logLine(output, str, 'INFO') 100 | } 101 | 102 | const lines = str.split('\n') 103 | for (let i = 0; i < lines.length - 1; i++) { 104 | const line = lines[i] 105 | const index = line.indexOf('__FAKE_LAMBDA_START__') 106 | 107 | if (index === -1) { 108 | if (line === '') continue 109 | this.logLine(output, line + '\n', 'INFO') 110 | continue 111 | } 112 | 113 | const start = line.slice(0, index) 114 | this.logLine(output, start) 115 | const endIndex = line.indexOf('__FAKE_LAMBDA_END__') 116 | 117 | const messageStr = line.slice(index + START_LEN, endIndex) 118 | const msgObject = JSON.parse(messageStr.trim()) 119 | handleMessage(msgObject) 120 | 121 | const end = line.slice(endIndex + END_LEN) 122 | if (end.length > 0) { 123 | this.logLine(output, end + '\n', 'INFO') 124 | } 125 | } 126 | 127 | const lastLine = lines[lines.length - 1] 128 | if (lastLine.includes('__FAKE_LAMBDA_START__')) { 129 | remainder = lastLine 130 | } else { 131 | this.logLine(output, remainder, 'INFO') 132 | } 133 | }) 134 | } 135 | 136 | async request (id, eventObject, raw) { 137 | this.latestId = id 138 | this.stdout.write( 139 | `START\tRequestId:${id}\tVersion:$LATEST\n` 140 | ) 141 | const start = Date.now() 142 | 143 | let proc 144 | /** @type {string | boolean} */ 145 | let shell = true 146 | if (process.platform !== 'win32') { 147 | shell = os.userInfo().shell 148 | } else { 149 | shell = false 150 | } 151 | 152 | if (/node(js):?(12|14|16)/.test(this.runtime)) { 153 | const parts = this.handler.split('.') 154 | const handlerField = parts[parts.length - 1] 155 | 156 | const cmd = process.platform === 'win32' ? process.execPath : 'node' 157 | 158 | proc = childProcess.spawn( 159 | cmd, 160 | [WORKER_PATH, this.entry, handlerField], 161 | { 162 | stdio: ['pipe', 'pipe', 'pipe'], 163 | detached: false, 164 | shell: shell, 165 | env: { 166 | PATH: process.env.PATH, 167 | ...this.env 168 | } 169 | } 170 | ) 171 | } else if (/python:?(3)/.test(this.runtime)) { 172 | const parts = this.handler.split('.') 173 | const handlerField = parts[parts.length - 1] 174 | 175 | const cmd = process.platform === 'win32' ? 'py' : 'python3' 176 | 177 | proc = childProcess.spawn( 178 | cmd, 179 | [PYTHON_WORKER_PATH, this.entry, handlerField], 180 | { 181 | // stdio: 'inherit', 182 | detached: false, 183 | shell: shell, 184 | env: { 185 | PATH: process.env.PATH, 186 | ...this.env 187 | } 188 | } 189 | ) 190 | } else if (/go:?(1)/.test(this.runtime)) { 191 | const workerPath = isWindows ? GO_WORKER_WINDOWS_PATH : GO_WORKER_POSIX_PATH 192 | const workerBin = workerPath.replace('.go', '') 193 | 194 | const buildCommand = `go build ${workerPath}` 195 | const buildOptions = { 196 | cwd: path.dirname(workerPath), 197 | shell: typeof shell === 'string' ? shell : undefined, 198 | env: { 199 | GOCACHE: process.env.GOCACHE, 200 | GOROOT: process.env.GOROOT, 201 | GOPATH: process.env.GOPATH, 202 | HOME: process.env.HOME, 203 | PATH: process.env.PATH, 204 | LOCALAPPDATA: process.env.LOCALAPPDATA, 205 | ...this.env 206 | } 207 | } 208 | 209 | const buildResult = await new Promise((resolve) => { 210 | childProcess.exec(buildCommand, buildOptions, (err, stderr) => resolve({ err, stderr })) 211 | }) 212 | 213 | if (buildResult.err) { 214 | return Promise.reject(buildResult.err) 215 | } 216 | 217 | if (buildResult.stderr) { 218 | const err = new Error('Internal Server Error') 219 | Reflect.set(err, 'errorString', buildResult.stderr) 220 | return Promise.reject(err) 221 | } 222 | 223 | proc = childProcess.spawn(workerBin, ['-p', '0', '-P', this.entry], { 224 | detached: false, 225 | shell: shell || true, 226 | cwd: path.dirname(workerPath), 227 | env: { 228 | GOCACHE: process.env.GOCACHE, 229 | GOROOT: process.env.GOROOT, 230 | GOPATH: process.env.GOPATH, 231 | HOME: process.env.HOME, 232 | PATH: process.env.PATH, 233 | LOCALAPPDATA: process.env.LOCALAPPDATA, 234 | ...this.env 235 | } 236 | }) 237 | } 238 | 239 | return new Promise((resolve, reject) => { 240 | this.procs.push(proc) 241 | proc.unref() 242 | 243 | let errorString = '' 244 | proc.stderr.on('data', (line) => { 245 | errorString += line.toString() 246 | 247 | this.logLine(this.stderr, line, 'ERR') 248 | }) 249 | 250 | this.parseStdout({ 251 | stdout: proc.stdout, 252 | output: this.stdout, 253 | handleMessage: (msg) => { 254 | const resultObject = this.handleMessage(msg, start) 255 | proc.kill() 256 | 257 | resolve(resultObject) 258 | } 259 | }) 260 | 261 | proc.once('exit', (code) => { 262 | code = code || 0 263 | 264 | if (code !== 0) { 265 | // var err = new Error() 266 | // err.message = error.split('\n')[0] 267 | // err.stack = error.split('\n').slice(1).join('\n') 268 | // const lambdaError = { 269 | // errorType: 'Error', 270 | // errorMessage: 'Error', 271 | // stack: errorString.split('\n') 272 | // } 273 | // this.stdout.write(`${new Date(start).toISOString()}\tundefined\tERROR\t${JSON.stringify(lambdaError)}\n`) 274 | 275 | const err = new Error('Internal Server Error') 276 | Reflect.set(err, 'errorString', errorString) 277 | Reflect.set(err, 'code', code) 278 | reject(err) 279 | // this is wrong, should not crash. 280 | } 281 | }) 282 | 283 | proc.on('error', function (err) { 284 | reject(err) 285 | }) 286 | 287 | proc.stdin.on('error', function (err) { 288 | /** 289 | * Sometimes we get an EPIPE exception when writing to 290 | * stdin for a process where the command is not found. 291 | * 292 | * We really care boure about the command not found error 293 | * so we swallow the EPIPE and let the other err bubble instead 294 | */ 295 | if (Reflect.get(err, 'code') === 'EPIPE') { 296 | return 297 | } 298 | 299 | reject(err) 300 | }) 301 | 302 | proc.stdin.write(JSON.stringify({ 303 | message: 'event', 304 | id, 305 | eventObject, 306 | raw: !!raw 307 | }) + '\n') 308 | process.stdin.end() 309 | }) 310 | } 311 | 312 | handleMessage (msg, start) { 313 | if (typeof msg !== 'object' || Object.is(msg, null)) { 314 | throw new Error('bad data type from child process') 315 | } 316 | 317 | const messageType = msg.message 318 | if (messageType !== 'result') { 319 | throw new Error('incorrect type field from child process:' + msg.type) 320 | } 321 | 322 | const id = msg.id 323 | if (typeof id !== 'string') { 324 | throw new Error('missing id from child process:' + msg.id) 325 | } 326 | 327 | const resultObj = msg.result 328 | // if (!checkResult(resultObj)) { 329 | // throw new Error('missing result from child process:' + msg.result) 330 | // } 331 | 332 | const duration = Date.now() - start 333 | 334 | // log like lambda 335 | this.stdout.write( 336 | `END\tRequestId: ${msg.id}\n` + 337 | `REPORT\tRequestId: ${msg.id}\t` + 338 | 'InitDuration: 0 ms\t' + 339 | `Duration: ${duration} ms\t` + 340 | `BilledDuration: ${Math.round(duration)} ms\t` + 341 | `Memory Size: NaN MB MaxMemoryUsed ${Math.round(msg.memory / (1024 * 1024))} MB\n` 342 | ) 343 | return resultObj 344 | } 345 | 346 | close () { 347 | this.procs.forEach(proc => proc.kill()) 348 | this.procs = [] 349 | } 350 | } 351 | 352 | module.exports = ChildProcessWorker 353 | -------------------------------------------------------------------------------- /workers/go-worker-posix-txt.js: -------------------------------------------------------------------------------- 1 | module.exports = /*go*/` 2 | // Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | // Copyright https://github.com/yogeshlonkar/aws-lambda-go-test 4 | package main 5 | 6 | import ( 7 | "bufio" 8 | "context" 9 | "encoding/json" 10 | "errors" 11 | "flag" 12 | "fmt" 13 | "log" 14 | "net" 15 | "net/rpc" 16 | "os" 17 | "os/exec" 18 | "path" 19 | "path/filepath" 20 | "strings" 21 | "strconv" 22 | "syscall" 23 | "time" 24 | ) 25 | 26 | type PingRequest struct { } 27 | type PingResponse struct { } 28 | 29 | //nolint:stylecheck 30 | type InvokeRequest_Timestamp struct { 31 | Seconds int64 32 | Nanos int64 33 | } 34 | 35 | //nolint:stylecheck 36 | type InvokeRequest struct { 37 | Payload []byte 38 | RequestId string //nolint:stylecheck 39 | XAmznTraceId string 40 | Deadline InvokeRequest_Timestamp 41 | InvokedFunctionArn string 42 | CognitoIdentityId string //nolint:stylecheck 43 | CognitoIdentityPoolId string //nolint:stylecheck 44 | ClientContext []byte 45 | } 46 | 47 | type InvokeResponse struct { 48 | Payload []byte 49 | Error *InvokeResponse_Error 50 | } 51 | 52 | //nolint:stylecheck 53 | type InvokeResponse_Error struct { 54 | Message string \`json:"errorMessage"\` 55 | Type string \`json:"errorType"\` 56 | StackTrace []*InvokeResponse_Error_StackFrame \`json:"stackTrace,omitempty"\` 57 | ShouldExit bool \`json:"-"\` 58 | } 59 | 60 | func (e InvokeResponse_Error) Error() string { 61 | return fmt.Sprintf("%#v", e) 62 | } 63 | 64 | //nolint:stylecheck 65 | type InvokeResponse_Error_StackFrame struct { 66 | Path string \`json:"path"\` 67 | Line int32 \`json:"line"\` 68 | Label string \`json:"label"\` 69 | } 70 | 71 | const functioninvokeRPC = "Function.Invoke" 72 | 73 | type Input struct { 74 | Delay time.Duration 75 | TimeOut time.Duration 76 | Port int 77 | AbsLambdaPath string 78 | Payload interface{} 79 | ClientContext *ClientContext 80 | Deadline *InvokeRequest_Timestamp 81 | } 82 | 83 | // LogGroupName is the name of the log group that contains the log streams of the current Lambda Function 84 | var LogGroupName string 85 | 86 | // LogStreamName name of the log stream that the current Lambda Function's logs will be sent to 87 | var LogStreamName string 88 | 89 | // FunctionName the name of the current Lambda Function 90 | var FunctionName string 91 | 92 | // MemoryLimitInMB is the configured memory limit for the current instance of the Lambda Function 93 | var MemoryLimitInMB int 94 | 95 | // FunctionVersion is the published version of the current instance of the Lambda Function 96 | var FunctionVersion string 97 | 98 | func init() { 99 | LogGroupName = os.Getenv("AWS_LAMBDA_LOG_GROUP_NAME") 100 | LogStreamName = os.Getenv("AWS_LAMBDA_LOG_STREAM_NAME") 101 | FunctionName = os.Getenv("AWS_LAMBDA_FUNCTION_NAME") 102 | if limit, err := strconv.Atoi(os.Getenv("AWS_LAMBDA_FUNCTION_MEMORY_SIZE")); err != nil { 103 | MemoryLimitInMB = 0 104 | } else { 105 | MemoryLimitInMB = limit 106 | } 107 | FunctionVersion = os.Getenv("AWS_LAMBDA_FUNCTION_VERSION") 108 | } 109 | 110 | // ClientApplication is metadata about the calling application. 111 | type ClientApplication struct { 112 | InstallationID string \`json:"installation_id"\` 113 | AppTitle string \`json:"app_title"\` 114 | AppVersionCode string \`json:"app_version_code"\` 115 | AppPackageName string \`json:"app_package_name"\` 116 | } 117 | 118 | // ClientContext is information about the client application passed by the calling application. 119 | type ClientContext struct { 120 | Client ClientApplication 121 | Env map[string]string \`json:"env"\` 122 | Custom map[string]string \`json:"custom"\` 123 | } 124 | 125 | // CognitoIdentity is the cognito identity used by the calling application. 126 | type CognitoIdentity struct { 127 | CognitoIdentityID string 128 | CognitoIdentityPoolID string 129 | } 130 | 131 | // LambdaContext is the set of metadata that is passed for every Invoke. 132 | type LambdaContext struct { 133 | AwsRequestID string //nolint: stylecheck 134 | InvokedFunctionArn string //nolint: stylecheck 135 | Identity CognitoIdentity 136 | ClientContext ClientContext 137 | } 138 | 139 | // An unexported type to be used as the key for types in this package. 140 | // This prevents collisions with keys defined in other packages. 141 | type key struct{} 142 | 143 | // The key for a LambdaContext in Contexts. 144 | // Users of this package must use lambdacontext.NewContext and lambdacontext.FromContext 145 | // instead of using this key directly. 146 | var contextKey = &key{} 147 | 148 | // NewContext returns a new Context that carries value lc. 149 | func NewContext(parent context.Context, lc *LambdaContext) context.Context { 150 | return context.WithValue(parent, contextKey, lc) 151 | } 152 | 153 | // FromContext returns the LambdaContext value stored in ctx, if any. 154 | func FromContext(ctx context.Context) (*LambdaContext, bool) { 155 | lc, ok := ctx.Value(contextKey).(*LambdaContext) 156 | return lc, ok 157 | } 158 | 159 | //Run a Go based lambda, passing the configured payload 160 | //note that 'payload' can be anything that can be encoded by encoding/json 161 | func Run(input Input) ([]byte, error) { 162 | input.setTimeOutIfZero() 163 | input.assignPortIfZero() 164 | tempExecution := input.startLambdaIfNotRunning() 165 | if tempExecution != nil { 166 | defer tempExecution() 167 | } 168 | if input.Delay != 0 { 169 | time.Sleep(input.Delay) 170 | } 171 | 172 | request, err := createInvokeRequest(input) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | // 2. Open a TCP connection to the lambda 178 | client, err := rpc.Dial("tcp", fmt.Sprintf(":%d", input.Port)) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | // 3. Issue an RPC request for the Function.Invoke method 184 | var response InvokeResponse 185 | 186 | if err = client.Call(functioninvokeRPC, request, &response); err != nil { 187 | return nil, err 188 | } 189 | 190 | if response.Error != nil { 191 | return nil, errors.New(response.Error.Message) 192 | } 193 | 194 | return response.Payload, nil 195 | } 196 | 197 | func (input *Input) startLambdaIfNotRunning() func() { 198 | conn, err := net.DialTimeout("tcp", net.JoinHostPort("", strconv.Itoa(input.Port)), input.TimeOut) 199 | if err != nil { 200 | connectionRefused := false 201 | switch t := err.(type) { 202 | case *net.OpError: 203 | if t.Op == "dial" || t.Op == "read" { 204 | connectionRefused = true 205 | } 206 | case syscall.Errno: 207 | if t == syscall.ECONNREFUSED { 208 | connectionRefused = true 209 | } 210 | } 211 | if connectionRefused { 212 | // run function if no service running on given port 213 | if input.AbsLambdaPath == "" { 214 | input.AbsLambdaPath = "main.go" 215 | } 216 | 217 | if err := os.Chdir(path.Dir(input.AbsLambdaPath)); err != nil { 218 | log.Fatal("failed to change directory to lambda project: ", err) 219 | } 220 | 221 | name := strings.ReplaceAll(input.AbsLambdaPath, ".go", "") 222 | build := exec.Command("go", "build", input.AbsLambdaPath) 223 | 224 | build.Dir = filepath.Dir(input.AbsLambdaPath) 225 | build.Stderr = os.Stderr 226 | build.Stdout = os.Stdout 227 | 228 | if err := build.Run(); err != nil { 229 | log.Fatal(err) 230 | } 231 | 232 | os.Chdir(build.Dir) 233 | cmd := exec.Command(name) 234 | 235 | cmd.Dir = filepath.Dir(input.AbsLambdaPath) 236 | cmd.Env = append( 237 | os.Environ(), 238 | fmt.Sprintf("_LAMBDA_SERVER_PORT=%d", input.Port), 239 | ) 240 | 241 | cmd.SysProcAttr = &syscall.SysProcAttr { 242 | // Pdeathsig: syscall.SIGTERM, 243 | Setpgid: true, 244 | } 245 | 246 | cmd.Stderr = os.Stderr 247 | cmd.Stdout = os.Stdout 248 | cmd.Stdin = os.Stdin 249 | 250 | if err := cmd.Start(); err != nil { 251 | log.Fatal(err) 252 | } 253 | 254 | time.Sleep(2 * time.Second) 255 | 256 | return func() { 257 | syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 258 | } 259 | } else { 260 | panic(err) 261 | } 262 | } 263 | if conn != nil { 264 | conn.Close() 265 | } 266 | return nil 267 | } 268 | 269 | // set default timeout to 2 seconds as the connection is 270 | // expected to be local 271 | func (input *Input) setTimeOutIfZero() { 272 | input.TimeOut = time.Second * 2 273 | } 274 | 275 | func (input *Input) assignPortIfZero() { 276 | if input.Port == 0 { 277 | listener, err := net.Listen("tcp", "127.0.0.1:0") 278 | if err != nil { 279 | panic(err) 280 | } 281 | defer listener.Close() 282 | input.Port = listener.Addr().(*net.TCPAddr).Port 283 | } 284 | } 285 | 286 | func createInvokeRequest(input Input) (*InvokeRequest, error) { 287 | payloadEncoded, err := json.Marshal(input.Payload) 288 | if err != nil { 289 | return nil, err 290 | } 291 | 292 | var clientContextEncoded []byte 293 | if input.ClientContext != nil { 294 | b, err := json.Marshal(input.ClientContext) 295 | 296 | if err != nil { 297 | return nil, err 298 | } 299 | 300 | clientContextEncoded = b 301 | } 302 | 303 | Deadline := input.Deadline 304 | 305 | if Deadline == nil { 306 | t := time.Now() 307 | Deadline = &InvokeRequest_Timestamp{ 308 | Seconds: int64(t.Unix()), 309 | Nanos: int64(t.Nanosecond()), 310 | } 311 | } 312 | 313 | return &InvokeRequest{ 314 | Payload: payloadEncoded, 315 | RequestId: "0", 316 | XAmznTraceId: "", 317 | Deadline: *Deadline, 318 | InvokedFunctionArn: "", 319 | CognitoIdentityId: "", 320 | CognitoIdentityPoolId: "", 321 | ClientContext: clientContextEncoded, 322 | }, nil 323 | } 324 | 325 | type Result struct { 326 | IsBase64Encoded bool \`json:"isBase64Encoded"\` 327 | StatusCode int \`json:"statusCode"\` 328 | Headers map[string]string \`json:"headers"\` 329 | Body string \`json:"body"\` 330 | } 331 | 332 | type ResultObject struct { 333 | Message string \`json:"message"\` 334 | Id string \`json:"id"\` 335 | Result Result \`json:"result"\` 336 | } 337 | 338 | type Event struct { 339 | Id string 340 | EventObject json.RawMessage 341 | } 342 | 343 | func main () { 344 | portFlag := flag.Int("p", 8888, "Port to run lambda on") 345 | pathFlag := flag.String("P", "", "Path to lambda file") 346 | 347 | flag.Parse() 348 | 349 | var lambdaPort = *portFlag 350 | var lambdaPath = *pathFlag 351 | 352 | stat, _ := os.Stdin.Stat() 353 | 354 | var event = Event { Id: "0" } 355 | 356 | if (stat.Mode() & os.ModeCharDevice) == 0 { 357 | reader := bufio.NewReader(os.Stdin) 358 | line, _ := reader.ReadString('\\n') 359 | 360 | json.Unmarshal([]byte(line), &event) 361 | } 362 | 363 | res, err := Run(Input { 364 | Port: lambdaPort, 365 | Payload: event.EventObject, 366 | AbsLambdaPath: lambdaPath, 367 | }) 368 | 369 | if err != nil { 370 | log.Fatal(err) 371 | } 372 | 373 | var result = Result { 374 | Headers: map[string]string{}, 375 | } 376 | 377 | json.Unmarshal(res, &result) 378 | 379 | if err != nil { 380 | log.Fatal(err) 381 | } 382 | 383 | encoded, err := json.Marshal(&ResultObject { 384 | Id: event.Id, 385 | Message: "result", 386 | Result: result, 387 | }) 388 | 389 | if err != nil { 390 | log.Fatal(err) 391 | } 392 | 393 | fmt.Printf("__FAKE_LAMBDA_START__%s__FAKE_LAMBDA_END__\\n", encoded) 394 | } 395 | ` 396 | -------------------------------------------------------------------------------- /workers/go-worker-windows-txt.js: -------------------------------------------------------------------------------- 1 | module.exports = /*go*/` 2 | // Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | // Copyright https://github.com/yogeshlonkar/aws-lambda-go-test 4 | package main 5 | 6 | import ( 7 | "bufio" 8 | "context" 9 | "encoding/json" 10 | "errors" 11 | "flag" 12 | "fmt" 13 | "log" 14 | "net" 15 | "net/rpc" 16 | "os" 17 | "os/exec" 18 | "path" 19 | "path/filepath" 20 | "strconv" 21 | "strings" 22 | "syscall" 23 | "time" 24 | ) 25 | 26 | type PingRequest struct { } 27 | type PingResponse struct { } 28 | 29 | //nolint:stylecheck 30 | type InvokeRequest_Timestamp struct { 31 | Seconds int64 32 | Nanos int64 33 | } 34 | 35 | //nolint:stylecheck 36 | type InvokeRequest struct { 37 | Payload []byte 38 | RequestId string //nolint:stylecheck 39 | XAmznTraceId string 40 | Deadline InvokeRequest_Timestamp 41 | InvokedFunctionArn string 42 | CognitoIdentityId string //nolint:stylecheck 43 | CognitoIdentityPoolId string //nolint:stylecheck 44 | ClientContext []byte 45 | } 46 | 47 | type InvokeResponse struct { 48 | Payload []byte 49 | Error *InvokeResponse_Error 50 | } 51 | 52 | //nolint:stylecheck 53 | type InvokeResponse_Error struct { 54 | Message string \`json:"errorMessage"\` 55 | Type string \`json:"errorType"\` 56 | StackTrace []*InvokeResponse_Error_StackFrame \`json:"stackTrace,omitempty"\` 57 | ShouldExit bool \`json:"-"\` 58 | } 59 | 60 | func (e InvokeResponse_Error) Error() string { 61 | return fmt.Sprintf("%#v", e) 62 | } 63 | 64 | //nolint:stylecheck 65 | type InvokeResponse_Error_StackFrame struct { 66 | Path string \`json:"path"\` 67 | Line int32 \`json:"line"\` 68 | Label string \`json:"label"\` 69 | } 70 | 71 | const functioninvokeRPC = "Function.Invoke" 72 | 73 | type Input struct { 74 | Delay time.Duration 75 | TimeOut time.Duration 76 | Port int 77 | AbsLambdaPath string 78 | Payload interface{} 79 | ClientContext *ClientContext 80 | Deadline *InvokeRequest_Timestamp 81 | } 82 | 83 | // LogGroupName is the name of the log group that contains the log streams of the current Lambda Function 84 | var LogGroupName string 85 | 86 | // LogStreamName name of the log stream that the current Lambda Function's logs will be sent to 87 | var LogStreamName string 88 | 89 | // FunctionName the name of the current Lambda Function 90 | var FunctionName string 91 | 92 | // MemoryLimitInMB is the configured memory limit for the current instance of the Lambda Function 93 | var MemoryLimitInMB int 94 | 95 | // FunctionVersion is the published version of the current instance of the Lambda Function 96 | var FunctionVersion string 97 | 98 | func init() { 99 | LogGroupName = os.Getenv("AWS_LAMBDA_LOG_GROUP_NAME") 100 | LogStreamName = os.Getenv("AWS_LAMBDA_LOG_STREAM_NAME") 101 | FunctionName = os.Getenv("AWS_LAMBDA_FUNCTION_NAME") 102 | if limit, err := strconv.Atoi(os.Getenv("AWS_LAMBDA_FUNCTION_MEMORY_SIZE")); err != nil { 103 | MemoryLimitInMB = 0 104 | } else { 105 | MemoryLimitInMB = limit 106 | } 107 | FunctionVersion = os.Getenv("AWS_LAMBDA_FUNCTION_VERSION") 108 | } 109 | 110 | // ClientApplication is metadata about the calling application. 111 | type ClientApplication struct { 112 | InstallationID string \`json:"installation_id"\` 113 | AppTitle string \`json:"app_title"\` 114 | AppVersionCode string \`json:"app_version_code"\` 115 | AppPackageName string \`json:"app_package_name"\` 116 | } 117 | 118 | // ClientContext is information about the client application passed by the calling application. 119 | type ClientContext struct { 120 | Client ClientApplication 121 | Env map[string]string \`json:"env"\` 122 | Custom map[string]string \`json:"custom"\` 123 | } 124 | 125 | // CognitoIdentity is the cognito identity used by the calling application. 126 | type CognitoIdentity struct { 127 | CognitoIdentityID string 128 | CognitoIdentityPoolID string 129 | } 130 | 131 | // LambdaContext is the set of metadata that is passed for every Invoke. 132 | type LambdaContext struct { 133 | AwsRequestID string //nolint: stylecheck 134 | InvokedFunctionArn string //nolint: stylecheck 135 | Identity CognitoIdentity 136 | ClientContext ClientContext 137 | } 138 | 139 | // An unexported type to be used as the key for types in this package. 140 | // This prevents collisions with keys defined in other packages. 141 | type key struct{} 142 | 143 | // The key for a LambdaContext in Contexts. 144 | // Users of this package must use lambdacontext.NewContext and lambdacontext.FromContext 145 | // instead of using this key directly. 146 | var contextKey = &key{} 147 | 148 | // NewContext returns a new Context that carries value lc. 149 | func NewContext(parent context.Context, lc *LambdaContext) context.Context { 150 | return context.WithValue(parent, contextKey, lc) 151 | } 152 | 153 | // FromContext returns the LambdaContext value stored in ctx, if any. 154 | func FromContext(ctx context.Context) (*LambdaContext, bool) { 155 | lc, ok := ctx.Value(contextKey).(*LambdaContext) 156 | return lc, ok 157 | } 158 | 159 | //Run a Go based lambda, passing the configured payload 160 | //note that 'payload' can be anything that can be encoded by encoding/json 161 | func Run(input Input) ([]byte, error) { 162 | input.setTimeOutIfZero() 163 | input.assignPortIfZero() 164 | tempExecution := input.startLambdaIfNotRunning() 165 | if tempExecution != nil { 166 | defer tempExecution() 167 | } 168 | if input.Delay != 0 { 169 | time.Sleep(input.Delay) 170 | } 171 | 172 | request, err := createInvokeRequest(input) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | // 2. Open a TCP connection to the lambda 178 | client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%d", input.Port)) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | // 3. Issue an RPC request for the Function.Invoke method 184 | var response InvokeResponse 185 | 186 | if err = client.Call(functioninvokeRPC, request, &response); err != nil { 187 | return nil, err 188 | } 189 | 190 | if response.Error != nil { 191 | return nil, errors.New(response.Error.Message) 192 | } 193 | 194 | return response.Payload, nil 195 | } 196 | 197 | func (input *Input) startLambdaIfNotRunning() func() { 198 | conn, err := net.DialTimeout("tcp", net.JoinHostPort("localhost", strconv.Itoa(input.Port)), input.TimeOut) 199 | if err != nil { 200 | connectionRefused := false 201 | switch t := err.(type) { 202 | case *net.OpError: 203 | if t.Op == "dial" || t.Op == "read" { 204 | connectionRefused = true 205 | } 206 | case syscall.Errno: 207 | if t == syscall.ECONNREFUSED { 208 | connectionRefused = true 209 | } 210 | } 211 | if connectionRefused { 212 | // run function if no service running on given port 213 | if input.AbsLambdaPath == "" { 214 | input.AbsLambdaPath = "main.go" 215 | } 216 | 217 | if err := os.Chdir(path.Dir(input.AbsLambdaPath)); err != nil { 218 | log.Fatal("failed to change directory to lambda project: ", err) 219 | } 220 | 221 | name := strings.ReplaceAll(input.AbsLambdaPath, ".go", "") 222 | build := exec.Command("go", "build", input.AbsLambdaPath) 223 | 224 | build.Dir = filepath.Dir(input.AbsLambdaPath) 225 | build.Env = os.Environ() 226 | build.Stderr = os.Stderr 227 | build.Stdout = os.Stdout 228 | 229 | if err := build.Run(); err != nil { 230 | log.Fatal(err) 231 | } 232 | 233 | os.Chdir(build.Dir) 234 | cmd := exec.Command(name) 235 | 236 | cmd.Env = append( 237 | os.Environ(), 238 | fmt.Sprintf("_LAMBDA_SERVER_PORT=%d", input.Port), 239 | ) 240 | 241 | cmd.Dir = filepath.Dir(input.AbsLambdaPath) 242 | 243 | cmd.Stderr = os.Stderr 244 | cmd.Stdout = os.Stdout 245 | cmd.Stdin = os.Stdin 246 | 247 | if err := cmd.Start(); err != nil { 248 | log.Fatal(err) 249 | } 250 | 251 | time.Sleep(2 * time.Second) 252 | 253 | return func() { 254 | cmd.Process.Kill() 255 | kill := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)) 256 | kill.Run() 257 | } 258 | } else { 259 | panic(err) 260 | } 261 | } 262 | if conn != nil { 263 | conn.Close() 264 | } 265 | return nil 266 | } 267 | 268 | // set default timeout to 2 seconds as the connection is 269 | // expected to be local 270 | func (input *Input) setTimeOutIfZero() { 271 | input.TimeOut = time.Second * 2 272 | } 273 | 274 | func (input *Input) assignPortIfZero() { 275 | if input.Port == 0 { 276 | listener, err := net.Listen("tcp", "127.0.0.1:0") 277 | if err != nil { 278 | panic(err) 279 | } 280 | defer listener.Close() 281 | input.Port = listener.Addr().(*net.TCPAddr).Port 282 | } 283 | } 284 | 285 | func createInvokeRequest(input Input) (*InvokeRequest, error) { 286 | payloadEncoded, err := json.Marshal(input.Payload) 287 | if err != nil { 288 | return nil, err 289 | } 290 | 291 | var clientContextEncoded []byte 292 | if input.ClientContext != nil { 293 | b, err := json.Marshal(input.ClientContext) 294 | 295 | if err != nil { 296 | return nil, err 297 | } 298 | 299 | clientContextEncoded = b 300 | } 301 | 302 | Deadline := input.Deadline 303 | 304 | if Deadline == nil { 305 | t := time.Now() 306 | Deadline = &InvokeRequest_Timestamp{ 307 | Seconds: int64(t.Unix()), 308 | Nanos: int64(t.Nanosecond()), 309 | } 310 | } 311 | 312 | return &InvokeRequest{ 313 | Payload: payloadEncoded, 314 | RequestId: "0", 315 | XAmznTraceId: "", 316 | Deadline: *Deadline, 317 | InvokedFunctionArn: "", 318 | CognitoIdentityId: "", 319 | CognitoIdentityPoolId: "", 320 | ClientContext: clientContextEncoded, 321 | }, nil 322 | } 323 | 324 | type Result struct { 325 | IsBase64Encoded bool \`json:"isBase64Encoded"\` 326 | StatusCode int \`json:"statusCode"\` 327 | Headers map[string]string \`json:"headers"\` 328 | Body string \`json:"body"\` 329 | } 330 | 331 | type ResultObject struct { 332 | Message string \`json:"message"\` 333 | Id string \`json:"id"\` 334 | Result Result \`json:"result"\` 335 | } 336 | 337 | type Event struct { 338 | Id string 339 | EventObject json.RawMessage 340 | } 341 | 342 | func main () { 343 | portFlag := flag.Int("p", 8888, "Port to run lambda on") 344 | pathFlag := flag.String("P", "", "Path to lambda file") 345 | 346 | flag.Parse() 347 | 348 | var lambdaPort = *portFlag 349 | var lambdaPath = *pathFlag 350 | 351 | stat, _ := os.Stdin.Stat() 352 | 353 | var event = Event { Id: "0" } 354 | 355 | if (stat.Mode() & os.ModeCharDevice) == 0 { 356 | reader := bufio.NewReader(os.Stdin) 357 | line, _ := reader.ReadString('\\n') 358 | 359 | json.Unmarshal([]byte(line), &event) 360 | } 361 | 362 | res, err := Run(Input { 363 | Port: lambdaPort, 364 | Payload: event.EventObject, 365 | AbsLambdaPath: lambdaPath, 366 | }) 367 | 368 | if err != nil { 369 | log.Fatal(err) 370 | } 371 | 372 | var result = Result { 373 | Headers: map[string]string{}, 374 | } 375 | 376 | json.Unmarshal(res, &result) 377 | 378 | if err != nil { 379 | log.Fatal(err) 380 | } 381 | 382 | encoded, err := json.Marshal(&ResultObject { 383 | Id: event.Id, 384 | Message: "result", 385 | Result: result, 386 | }) 387 | 388 | if err != nil { 389 | log.Fatal(err) 390 | } 391 | 392 | fmt.Printf("__FAKE_LAMBDA_START__%s__FAKE_LAMBDA_END__\\n", encoded) 393 | } 394 | ` 395 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 'use strict' 3 | 4 | const http = require('http') 5 | const https = require('https') 6 | const util = require('util') 7 | const url = require('url') 8 | const assert = require('assert') 9 | const URL = require('url').URL 10 | 11 | const ChildProcessWorker = require('./child-process-worker') 12 | 13 | /** 14 | @typedef {{ 15 | resource: string; 16 | path: string; 17 | httpMethod: string; 18 | headers: Record; 19 | multiValueHeaders: Record; 20 | queryStringParameters: Record; 21 | multiValueQueryStringParameters: Record; 22 | pathParameters: Record; 23 | stageVariables: Record; 24 | requestContext: object; 25 | body: string; 26 | isBase64Encoded: boolean; 27 | }} LambdaEvent 28 | 29 | @typedef {{ 30 | (eventObject: LambdaEvent): Promise | object; 31 | }} PopulateRequestContextFn 32 | 33 | @typedef {{ 34 | port?: number; 35 | httpsPort?: number; 36 | httpsKey?: string; 37 | httpsCert?: string; 38 | enableCors?: boolean; 39 | silent?: boolean; 40 | populateRequestContext?: PopulateRequestContextFn; 41 | tmp?: string; 42 | }} Options 43 | 44 | @typedef {{ 45 | isBase64Encoded: boolean; 46 | statusCode: number; 47 | headers: Record; 48 | multiValueHeaders?: Record; 49 | body: string; 50 | }} LambdaResult 51 | 52 | @typedef {{ 53 | path: string, 54 | functionName: string, 55 | worker: ChildProcessWorker 56 | }} FunctionInfo 57 | */ 58 | class FakeApiGatewayLambda { 59 | /** 60 | * @param {Options} options 61 | */ 62 | constructor (options) { 63 | /** @type {http.Server | null} */ 64 | this.httpServer = http.createServer() 65 | this._tmp = options.tmp 66 | 67 | /** @type {https.Server | null} */ 68 | this.httpsServer = null 69 | if (options.httpsKey && options.httpsCert && options.httpsPort) { 70 | this.httpsServer = https.createServer({ 71 | key: options.httpsKey, 72 | cert: options.httpsCert 73 | }) 74 | } 75 | 76 | /** @type {number | null} */ 77 | this.httpsPort = options.httpsPort || null 78 | /** @type {number} */ 79 | this.port = options.port || 0 80 | 81 | /** @type {Record} */ 82 | this.functions = {} 83 | 84 | /** @type {boolean} */ 85 | this.enableCors = options.enableCors || false 86 | /** @type {boolean} */ 87 | this.silent = options.silent || false 88 | /** @type {string | null} */ 89 | this.hostPort = null 90 | 91 | /** 92 | * @type {Map} 97 | */ 98 | this.pendingRequests = new Map() 99 | /** @type {string} */ 100 | this.gatewayId = cuuid() 101 | /** @type {PopulateRequestContextFn | null} */ 102 | this.populateRequestContext = options.populateRequestContext || null 103 | } 104 | 105 | async bootstrap () { 106 | if (!this.httpServer) { 107 | throw new Error('cannot bootstrap closed server') 108 | } 109 | 110 | this.httpServer.on('request', ( 111 | /** @type {http.IncomingMessage} */ req, 112 | /** @type {http.ServerResponse} */ res 113 | ) => { 114 | this._handleServerRequest(req, res) 115 | }) 116 | 117 | if (this.httpsServer) { 118 | this.httpsServer.on('request', ( 119 | /** @type {http.IncomingMessage} */ req, 120 | /** @type {http.ServerResponse} */ res 121 | ) => { 122 | this._handleServerRequest(req, res) 123 | }) 124 | 125 | const httpsServer = this.httpsServer 126 | await util.promisify((cb) => { 127 | httpsServer.listen(this.httpsPort, '127.0.0.1', () => { 128 | cb(null, null) 129 | }) 130 | })() 131 | } 132 | 133 | const server = this.httpServer 134 | try { 135 | await util.promisify((cb) => { 136 | server.on('listening', () => { 137 | cb(null, null) 138 | }) 139 | server.on('error', (err) => { 140 | cb(err) 141 | }) 142 | server.listen(this.port, '127.0.0.1') 143 | })() 144 | } catch (err) { 145 | return { err } 146 | } 147 | 148 | const addr = this.httpServer.address() 149 | if (!addr || typeof addr === 'string') { 150 | throw new Error('invalid http server address') 151 | } 152 | 153 | this.hostPort = `127.0.0.1:${addr.port}` 154 | return { data: this.hostPort } 155 | } 156 | 157 | /** 158 | * @param {number} newPort 159 | */ 160 | async changePort (newPort) { 161 | this.port = newPort 162 | 163 | if (this.httpServer) { 164 | this.httpServer.close() 165 | this.httpServer = null 166 | this.httpServer = http.createServer() 167 | } 168 | if (this.httpsServer) { 169 | this.httpsServer.close() 170 | this.httpsServer = null 171 | } 172 | 173 | return await this.bootstrap() 174 | } 175 | 176 | hasWorker (httpPath) { 177 | return Object.values(this.functions).some((f) => { 178 | return f.path === httpPath 179 | }) 180 | } 181 | 182 | getWorker (httpPath) { 183 | return Object.values(this.functions).find((f) => { 184 | return f.path === httpPath 185 | }) 186 | } 187 | 188 | /** 189 | * @param {{ 190 | * stdout?: object, 191 | * stderr?: object, 192 | * handler?: string, 193 | * env?: Record, 194 | * entry: string, 195 | * functionName: string, 196 | * runtime?: string 197 | * httpPath: string 198 | * }} info 199 | * @returns {FunctionInfo} 200 | */ 201 | updateWorker (info) { 202 | assert(info.functionName, 'functionName required') 203 | assert(info.handler, 'info.handler required') 204 | assert(info.runtime, 'info.runtime required') 205 | assert(info.entry, 'info.entry required') 206 | 207 | const opts = { 208 | env: info.env, 209 | runtime: info.runtime, 210 | stdout: info.stdout, 211 | stderr: info.stderr, 212 | tmp: this._tmp, 213 | handler: info.handler, 214 | entry: info.entry 215 | } 216 | 217 | /** @type {FunctionInfo} */ 218 | const fun = { 219 | worker: new ChildProcessWorker(opts), 220 | functionName: info.functionName, 221 | path: info.httpPath 222 | } 223 | 224 | this.functions[info.functionName] = fun 225 | return fun 226 | } 227 | 228 | /** 229 | * @returns {Promise} 230 | */ 231 | async close () { 232 | if (this.httpServer) { 233 | await util.promisify((cb) => { 234 | this.httpServer.close(() => { 235 | cb(null, null) 236 | }) 237 | })() 238 | this.httpServer = null 239 | } 240 | 241 | if (this.httpsServer) { 242 | await util.promisify((cb) => { 243 | this.httpsServer.close(() => { 244 | cb(null, null) 245 | }) 246 | })() 247 | this.httpsServer = null 248 | } 249 | 250 | await Promise.all(Object.values(this.functions).map(f => { 251 | return f.worker.close() 252 | })) 253 | } 254 | 255 | /** 256 | * @param {string} id 257 | * @param {object} eventObject 258 | * @returns {Promise} 259 | */ 260 | async _dispatch (id, eventObject) { 261 | const url = new URL(eventObject.path, 'http://localhost:80') 262 | 263 | const functions = Object.values(this.functions) 264 | const matched = matchRoute(functions, url.pathname) 265 | if (matched) { 266 | eventObject.resource = matched.path 267 | return matched.worker.request(id, eventObject) 268 | } else { 269 | return { 270 | isBase64Encoded: false, 271 | statusCode: 404, // the real api-gateway does a 403. 272 | headers: {}, 273 | body: JSON.stringify({ message: 'NotFound: The local server does not have this URL path' }), 274 | multiValueHeaders: {} 275 | } 276 | } 277 | 278 | // before, the error didn't happen until it got to the worker, 279 | // but now the worker only has one lambda so it's here now. 280 | } 281 | 282 | /** 283 | * @param {string} id 284 | * @returns {any} 285 | */ 286 | _hasPendingRequest (id) { 287 | return this.pendingRequests.has(id) 288 | } 289 | 290 | /** 291 | * @param {string} id 292 | * @param {LambdaResult} result 293 | * @returns {void} 294 | */ 295 | _handleLambdaResult (id, result) { 296 | const pending = this.pendingRequests.get(id) 297 | if (!pending) { 298 | /** 299 | * @raynos TODO: gracefully handle this edgecase. 300 | */ 301 | throw new Error('response without request: should never happen') 302 | } 303 | 304 | this.pendingRequests.delete(id) 305 | 306 | const res = pending.res 307 | res.statusCode = result.statusCode 308 | 309 | for (const key of Object.keys(result.headers || {})) { 310 | res.setHeader(key, result.headers[key]) 311 | } 312 | if (result.multiValueHeaders) { 313 | for (const key of Object.keys(result.multiValueHeaders)) { 314 | res.setHeader(key, result.multiValueHeaders[key]) 315 | } 316 | } 317 | 318 | res.end(result.body) 319 | } 320 | 321 | /** 322 | * @param {http.IncomingMessage} req 323 | * @param {http.ServerResponse} res 324 | * @returns {void} 325 | */ 326 | _handleServerRequest ( 327 | req, 328 | res 329 | ) { 330 | if (this.enableCors) { 331 | res.setHeader('Access-Control-Allow-Origin', 332 | req.headers.origin || '*' 333 | ) 334 | res.setHeader('Access-Control-Allow-Methods', 335 | 'POST, GET, PUT, DELETE, OPTIONS, XMODIFY' 336 | ) 337 | res.setHeader('Access-Control-Allow-Credentials', 'true') 338 | res.setHeader('Access-Control-Max-Age', '86400') 339 | res.setHeader('Access-Control-Allow-Headers', 340 | 'X-Requested-With, X-HTTP-Method-Override, ' + 341 | 'Content-Type, Accept, Authorization' 342 | ) 343 | } 344 | 345 | if (this.enableCors && req.method === 'OPTIONS') { 346 | res.end() 347 | return 348 | } 349 | 350 | const reqUrl = req.url || '/' 351 | 352 | // eslint-disable-next-line node/no-deprecated-api 353 | const uriObj = url.parse(reqUrl, true) 354 | 355 | if (reqUrl.startsWith('/___FAKE_API_GATEWAY_LAMBDA___RAW___')) { 356 | this._dispatchRaw(req, uriObj, res) 357 | return 358 | } 359 | 360 | // if a referer header is present, 361 | // check that the request is from a page we hosted 362 | // otherwise, the request could be a locally open web page. 363 | // which could be an attacker. 364 | if (!this.enableCors && req.headers.referer) { 365 | // eslint-disable-next-line node/no-deprecated-api 366 | const referer = url.parse(req.headers.referer) 367 | if (referer.hostname !== 'localhost' && referer.hostname !== '127.0.0.1') { 368 | res.statusCode = 403 369 | return res.end(JSON.stringify({ message: 'expected request from localhost' }, null, 2)) 370 | } 371 | // allow other ports. locally running apps are trusted, because the user had to start them. 372 | } 373 | 374 | // if the host header is not us, the request *thought* it was going to something else 375 | // this could be a DNS poisoning attack. 376 | const host = req.headers.host && req.headers.host.split(':')[0] 377 | if (host !== 'localhost' && host !== '127.0.0.1') { 378 | // error - dns poisoning attack 379 | res.statusCode = 403 380 | return res.end(JSON.stringify({ message: 'unexpected host header' }, null, 2)) 381 | } 382 | 383 | let body = '' 384 | req.on('data', (/** @type {Buffer} */ chunk) => { 385 | body += chunk.toString() 386 | }) 387 | req.on('end', () => { 388 | const eventObject = { 389 | resource: null, 390 | path: req.url ? req.url : '/', 391 | httpMethod: req.method ? req.method : 'GET', 392 | headers: flattenHeaders(req.rawHeaders), 393 | multiValueHeaders: multiValueHeaders(req.rawHeaders), 394 | queryStringParameters: 395 | singleValueQueryString(uriObj.query), 396 | multiValueQueryStringParameters: 397 | multiValueObject(uriObj.query), 398 | pathParameters: {}, 399 | stageVariables: {}, 400 | requestContext: {}, 401 | body, 402 | isBase64Encoded: false 403 | } 404 | 405 | this._dispatchPayload(req, res, eventObject) 406 | }) 407 | } 408 | 409 | async _dispatchRaw (req, uriObj, res) { 410 | const functionName = uriObj.query.functionName 411 | 412 | const func = this.functions[functionName] 413 | if (!func) { 414 | res.statusCode = 404 415 | return res.end(JSON.stringify({ 416 | message: `Not Found (${functionName})` 417 | })) 418 | } 419 | 420 | // get req body 421 | let body = '' 422 | req.on('data', (/** @type {Buffer} */ chunk) => { 423 | body += chunk.toString() 424 | }) 425 | req.once('end', async () => { 426 | const eventObject = JSON.parse(body) 427 | 428 | const id = cuuid() 429 | 430 | let result 431 | try { 432 | result = await func.worker.request(id, eventObject, true) 433 | } catch (err) { 434 | const str = JSON.stringify({ 435 | message: err.message, 436 | stack: err.errorString 437 | ? err.errorString.split('\n') 438 | : undefined 439 | }, null, 2) 440 | 441 | res.statusCode = 500 442 | res.setHeader('Content-Type', 'application/json') 443 | res.end(str) 444 | return 445 | } 446 | 447 | res.statusCode = 200 448 | res.setHeader('Content-Type', 'application/json') 449 | res.end(JSON.stringify(result)) 450 | }) 451 | } 452 | 453 | async _dispatchPayload (req, res, eventObject) { 454 | /** 455 | * @raynos TODO: Need to identify what concrete value 456 | * to use for `event.resource` and for `event.pathParameters` 457 | * since these are based on actual configuration in AWS 458 | * API Gateway. Maybe these should come from the `routes` 459 | * options object itself 460 | */ 461 | if (this.populateRequestContext) { 462 | const reqContext = await this.populateRequestContext(eventObject) 463 | eventObject.requestContext = reqContext 464 | } 465 | 466 | const id = cuuid() 467 | this.pendingRequests.set(id, { req, res, id }) 468 | 469 | let lambdaResult 470 | try { 471 | lambdaResult = await this._dispatch(id, eventObject) 472 | const isValid = checkResult(lambdaResult) 473 | if (!isValid) { 474 | throw new Error('Lambda returned invalid HTTP result') 475 | } 476 | } catch (err) { 477 | this._handleLambdaResult(id, { 478 | statusCode: 500, 479 | isBase64Encoded: false, 480 | headers: {}, 481 | body: JSON.stringify({ 482 | message: err.message, 483 | stack: err.errorString 484 | ? err.errorString.split('\n') 485 | : undefined 486 | }, null, 2) 487 | }) 488 | return 489 | } 490 | 491 | this._handleLambdaResult(id, lambdaResult) 492 | } 493 | } 494 | 495 | exports.FakeApiGatewayLambda = FakeApiGatewayLambda 496 | 497 | /** 498 | * @param {Record} qs 499 | * @returns {Record} 500 | */ 501 | function singleValueQueryString (qs) { 502 | /** @type {Record} */ 503 | const out = {} 504 | for (const key of Object.keys(qs)) { 505 | const v = qs[key] 506 | out[key] = typeof v === 'string' ? v : v[v.length - 1] 507 | } 508 | return out 509 | } 510 | 511 | /** 512 | * @param {Record} h 513 | * @returns {Record} 514 | */ 515 | function multiValueObject (h) { 516 | /** @type {Record} */ 517 | const out = {} 518 | for (const key of Object.keys(h)) { 519 | const v = h[key] 520 | if (typeof v === 'string') { 521 | out[key] = [v] 522 | } else if (Array.isArray(v)) { 523 | out[key] = v 524 | } 525 | } 526 | return out 527 | } 528 | 529 | /** 530 | * @param {string[]} h 531 | * @returns {Record} 532 | */ 533 | function multiValueHeaders (h) { 534 | /** @type {Record} */ 535 | const out = {} 536 | for (let i = 0; i < h.length; i += 2) { 537 | const headerName = h[i] 538 | const headerValue = h[i + 1] 539 | 540 | if (!(headerName in out)) { 541 | out[headerName] = [headerValue] 542 | } else { 543 | out[headerName].push(headerValue) 544 | } 545 | } 546 | return out 547 | } 548 | 549 | /** 550 | * @param {string[]} h 551 | * @returns {Record} 552 | */ 553 | function flattenHeaders (h) { 554 | /** @type {Record} */ 555 | const out = {} 556 | /** @type {string[]} */ 557 | const deleteList = [] 558 | for (let i = 0; i < h.length; i += 2) { 559 | const headerName = h[i] 560 | const headerValue = h[i + 1] 561 | 562 | if (!(headerName in out)) { 563 | out[headerName] = headerValue 564 | } else { 565 | deleteList.push(headerName) 566 | } 567 | } 568 | for (const key of deleteList) { 569 | delete out[key] 570 | } 571 | return out 572 | } 573 | 574 | /** 575 | * @returns {string} 576 | */ 577 | function cuuid () { 578 | const str = (Date.now().toString(16) + Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2)).slice(0, 32) 579 | return str.slice(0, 8) + '-' + str.slice(8, 12) + '-' + str.slice(12, 16) + '-' + str.slice(16, 20) + '-' + str.slice(20) 580 | } 581 | 582 | /** 583 | * @param {FunctionInfo[]} functions 584 | * @param {string} pathname 585 | * @returns {FunctionInfo | null} 586 | */ 587 | function matchRoute (functions, pathname) { 588 | // what if a path has more than one pattern element? 589 | return functions.find(fun => { 590 | const route = fun.path 591 | if (!route) { 592 | return false 593 | } 594 | 595 | const routeSegments = route.split('/').slice(1) 596 | const pathSegments = pathname.split('/').slice(1) 597 | 598 | const endsInGlob = route.endsWith('+}') 599 | 600 | if ( 601 | !endsInGlob && 602 | routeSegments.length !== pathSegments.length 603 | ) { 604 | return false 605 | } 606 | 607 | for (let i = 0; i < routeSegments.length; i++) { 608 | const routeSegment = routeSegments[i] 609 | const pathSegment = pathSegments[i] 610 | 611 | if (!pathSegment && pathSegment !== '') { 612 | return false 613 | } 614 | 615 | if (!routeSegment.startsWith('{')) { 616 | if (routeSegment !== pathSegment) { 617 | return false 618 | } 619 | } 620 | 621 | if (routeSegment.startsWith('{') && pathSegment === '') { 622 | return false 623 | } 624 | } 625 | 626 | return true 627 | }) 628 | } 629 | 630 | /** 631 | * @param {unknown} v 632 | */ 633 | function checkResult (v) { 634 | if (typeof v !== 'object' || !v) { 635 | return false 636 | } 637 | 638 | const objValue = v 639 | if (typeof Reflect.get(objValue, 'isBase64Encoded') !== 'boolean') { 640 | return false 641 | } 642 | if (typeof Reflect.get(objValue, 'statusCode') !== 'number') { 643 | return false 644 | } 645 | if (typeof Reflect.get(objValue, 'headers') !== 'object') { 646 | return false 647 | } 648 | 649 | const mvHeaders = /** @type {unknown} */ (Reflect.get(objValue, 'multiValueHeaders')) 650 | if (mvHeaders && typeof mvHeaders !== 'object') { 651 | return false 652 | } 653 | if (typeof Reflect.get(objValue, 'body') !== 'string') { 654 | return false 655 | } 656 | 657 | return true 658 | } 659 | --------------------------------------------------------------------------------