diff --git a/.eslintignore b/.eslintignore index 211df6524..cf7098890 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1 @@ -manual_test_nodejs -manual_test_python -manual_test_ruby -manual_test_websocket +**/node_modules diff --git a/.eslintrc.js b/.eslintrc.js index 5c7d6c559..5dac96b4b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,7 @@ const rules = { 'key-spacing': 'off', 'no-restricted-syntax': 'off', 'prefer-destructuring': 'off', + 'one-var-declaration-per-line': ['error', 'always'], semi: ['error', 'always'], strict: 'off', }; @@ -21,4 +22,8 @@ if (env.TRAVIS && platform === 'win32') { module.exports = { extends: 'dherault', rules, + env: { + node: true, + mocha: true, + }, }; diff --git a/.gitignore b/.gitignore index a6c6005e8..56074c756 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ fabric.properties # auto-generated tag files tags ======= - -.idea/ -manual_test/.serverless + +.idea/ +.serverless +.dynamodb diff --git a/README.md b/README.md index 6a07ba938..1876c1d6b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This plugin is updated by its users, I just do maintenance and ensure that PRs a * [Custom headers](#custom-headers) * [Environment variables](#environment-variables) * [AWS API Gateway features](#aws-api-gateway-features) +* [WebSocket](#websocket) * [Usage with Webpack](#usage-with-webpack) * [Velocity nuances](#velocity-nuances) * [Debug process](#debug-process) @@ -75,6 +76,7 @@ All CLI options are optional: --location -l The root location of the handlers' files. Defaults to the current directory --host -o Host name to listen on. Default: localhost --port -P Port to listen on. Default: 3000 +--websocketPort WebSocket port to listen on. Default: 3001 --stage -s The stage used to populate your templates. Default: the first stage found in your project. --region -r The region used to populate your templates. Default: the first region for the first stage found. --noTimeout -t Disables the timeout feature. @@ -359,6 +361,34 @@ resources: To disable the model validation you can use `--disableModelValidation`. +## WebSocket + +:warning: *This is an experimental functionality. Please report any bugs or missing features. PRs are welcome.* + +Usage in order to send messages back to clients: + +`POST http://localhost:{websocketPort}/@connections/{connectionId}` + +Or, + +```js +const apiGatewayManagementApi = new AWS.ApiGatewayManagementApi({ + apiVersion: '2018-11-29', + endpoint: event.apiGatewayUrl || `${event.requestContext.domainName}/${event.requestContext.stage}`, +}); + +apiGatewayManagementApi.postToConnection({ + ConnectionId: ..., + Data: ..., +}); +``` + +Where the `event` is received in the lambda handler function. + +There's support for [websocketsApiRouteSelectionExpression](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-selection-expressions.html) in it's basic form: `$request.body.x.y.z`, where the default value is `$request.body.action`. + +Authorizers and wss:// are currectly not supported in this feature. + ## Usage with Webpack Use [serverless-webpack](https://github.com/serverless-heaven/serverless-webpack) to compile and bundle your ES-next code diff --git a/manual_test_nodejs/handler.js b/manual_test_nodejs/handler.js index b52c1521f..a7f37dabe 100644 --- a/manual_test_nodejs/handler.js +++ b/manual_test_nodejs/handler.js @@ -1,4 +1,3 @@ -'use strict'; module.exports.hello = (event, context, callback) => { const response = { @@ -28,7 +27,7 @@ module.exports.rejectedPromise = (event, context, callback) => { callback(null, response); }; -module.exports.authFunction = (event, context, callback) => { +module.exports.authFunction = (event, context) => { context.succeed({ principalId: 'xxxxxxx', // the principal user identification associated with the token send by the client policyDocument: { @@ -98,6 +97,6 @@ module.exports.pathParams = (event, context, cb) => { cb(null, response); }; -module.exports.failure = (event, context, cb) => { - throw new Error('Unexpected error!') +module.exports.failure = () => { + throw new Error('Unexpected error!'); }; diff --git a/manual_test_nodejs/subprocess.js b/manual_test_nodejs/subprocess.js index 913220774..7794c64f7 100644 --- a/manual_test_nodejs/subprocess.js +++ b/manual_test_nodejs/subprocess.js @@ -1,4 +1,3 @@ -'use strict'; const { exec } = require('child_process'); diff --git a/manual_test_websocket/.gitignore b/manual_test_websocket/.gitignore new file mode 100644 index 000000000..fd983bc6b --- /dev/null +++ b/manual_test_websocket/.gitignore @@ -0,0 +1 @@ +/**/serverless.yml diff --git a/manual_test_websocket/README.md b/manual_test_websocket/README.md index d1ad7bddb..7eb75bafd 100644 --- a/manual_test_websocket/README.md +++ b/manual_test_websocket/README.md @@ -8,6 +8,7 @@ Set AWS credentials, e.g.: `export AWS_PROFILE=...` To start AWS DynamoDB locally (can run only after first deploying locally): `sls dynamodb install` `sls dynamodb start` +### In either main or RouteSelection folder the following: ## Deploying locally @@ -26,28 +27,24 @@ To start AWS DynamoDB locally (can run only after first deploying locally): `sls ## Testing on AWS -`npm --endpoint={WebSocket endpoint URL on AWS} run test` +`npm --endpoint={WebSocket endpoint URL on AWS} --timeout={timeout in ms} run test` -## Usage Assumption - In order to send messages back to clients -`const newAWSApiGatewayManagementApi=(event, context)=>{` +## Usage in order to send messages back to clients + +`POST http://localhost:3001/@connections/{connectionId}` -` const endpoint=event.requestContext.domainName+'/'+event.requestContext.stage;` +Or, -` const apiVersion='2018-11-29';` +`let endpoint=event.apiGatewayUrl;` -` let API=context.API;` +`if (!endpoint) endpoint = event.requestContext.domainName+'/'+event.requestContext.stage;` -` if (!process.env.IS_OFFLINE) {` +`const apiVersion='2018-11-29';` -` API = require('aws-sdk');` +`const apiGM=new API.ApiGatewayManagementApi({ apiVersion, endpoint });` -` require('aws-sdk/clients/apigatewaymanagementapi');` +`apiGM.postToConnection({ConnectionId, Data});` -` }` - -` return new API.ApiGatewayManagementApi({ apiVersion, endpoint });` - -`};` diff --git a/manual_test_websocket/RouteSelection/handler.js b/manual_test_websocket/RouteSelection/handler.js new file mode 100644 index 000000000..d43ffebc5 --- /dev/null +++ b/manual_test_websocket/RouteSelection/handler.js @@ -0,0 +1,31 @@ +const AWS = require('aws-sdk'); + +const successfullResponse = { + statusCode: 200, + body: 'Request is OK.', +}; + +module.exports.echo = async (event, context) => { + const action = JSON.parse(event.body); + + await sendToClient(action.message, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)); + + return successfullResponse; +}; + +const newAWSApiGatewayManagementApi = event => { + let endpoint = event.apiGatewayUrl; + + if (!endpoint) endpoint = `${event.requestContext.domainName}/${event.requestContext.stage}`; + const apiVersion = '2018-11-29'; + + return new AWS.ApiGatewayManagementApi({ apiVersion, endpoint }); +}; + +const sendToClient = (data, connectionId, apigwManagementApi) => { + // console.log(`sendToClient:${connectionId}`); + let sendee = data; + if (typeof data === 'object') sendee = JSON.stringify(data); + + return apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: sendee }).promise(); +}; diff --git a/manual_test_websocket/package-lock.json b/manual_test_websocket/RouteSelection/package-lock.json similarity index 82% rename from manual_test_websocket/package-lock.json rename to manual_test_websocket/RouteSelection/package-lock.json index acb22d0c2..00ea0b7b6 100644 --- a/manual_test_websocket/package-lock.json +++ b/manual_test_websocket/RouteSelection/package-lock.json @@ -4,6 +4,34 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/chai": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.1.7.tgz", + "integrity": "sha512-2Y8uPt0/jwjhQ6EiluT0XCri1Dbplr0ZxfFXUz+ye13gaqE8u5gL5ppao1JrUYr9cIip5S6MvQzBS7Kke7U9VA==", + "dev": true + }, + "@types/cookiejar": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz", + "integrity": "sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw==", + "dev": true + }, + "@types/node": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.2.tgz", + "integrity": "sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA==", + "dev": true + }, + "@types/superagent": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-3.8.7.tgz", + "integrity": "sha512-9KhCkyXv268A2nZ1Wvu7rQWM+BmdYUVkycFeNnYrUL5Zwu7o8wPQ3wBfW59dDP+wuoxw0ww8YKgTNv8j/cgscA==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, "ansi-colors": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", @@ -46,10 +74,16 @@ "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==", "dev": true }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, "aws-sdk": { - "version": "2.449.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.449.0.tgz", - "integrity": "sha512-ywvqLoBUlibAkud+A3eXZbGv6pBZwqb/DolYvJJR834E8Dvp8+bYZY1+gCDe9a5hp15ICb2jD+vOM2W6ljUlHw==", + "version": "2.482.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.482.0.tgz", + "integrity": "sha512-4MYfQZ+SETyjOUFZLEWVEhBqmxFi6MeI0X8FfAFhczb680+8PCKx/pWZHKLAR41k8+Lg5egM+fId0xtCaCSaeQ==", "requires": { "buffer": "4.9.1", "events": "1.1.1", @@ -62,6 +96,18 @@ "xml2js": "0.4.19" } }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", + "dev": true + }, + "awscred": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/awscred/-/awscred-1.4.2.tgz", + "integrity": "sha512-j3Vehf6PCFzzPZKkzEcj0Y2QO8w8UBbgobnl3DwHMiAE9A2mfJxTkq3cX4UNWHmrTAR0rj5BC/ts90Ok4Pg6rw==", + "dev": true + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -134,6 +180,21 @@ "type-detect": "^4.0.5" } }, + "chai-http": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/chai-http/-/chai-http-4.3.0.tgz", + "integrity": "sha512-zFTxlN7HLMv+7+SPXZdkd5wUlK+KxH6Q7bIEMiEx0FK3zuuMqL7cwICAQ0V1+yYRozBburYuxN1qZstgHpFZQg==", + "dev": true, + "requires": { + "@types/chai": "4", + "@types/superagent": "^3.8.3", + "cookiejar": "^2.1.1", + "is-ip": "^2.0.0", + "methods": "^1.1.2", + "qs": "^6.5.1", + "superagent": "^3.7.0" + } + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -194,12 +255,39 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -246,6 +334,12 @@ "object-keys": "^1.0.12" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, "diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", @@ -336,6 +430,12 @@ "strip-eof": "^1.0.0" } }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, "find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -354,6 +454,23 @@ "is-buffer": "~2.0.3" } }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -479,6 +596,12 @@ "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", "dev": true }, + "ip-regex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", + "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", + "dev": true + }, "is": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/is/-/is-0.2.7.tgz", @@ -509,6 +632,15 @@ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", "dev": true }, + "is-ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-2.0.0.tgz", + "integrity": "sha1-aO6gfooKCpTC0IDdZ0xzGrKkYas=", + "dev": true, + "requires": { + "ip-regex": "^2.0.0" + } + }, "is-regex": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", @@ -613,6 +745,33 @@ "p-is-promise": "^2.0.0" } }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "dev": true + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, + "requires": { + "mime-db": "1.40.0" + } + }, "mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -674,6 +833,12 @@ "yargs-unparser": "1.5.0" } }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==", + "dev": true + }, "ms": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", @@ -852,6 +1017,12 @@ "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", "dev": true }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, "progress": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", @@ -873,11 +1044,32 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", + "dev": true + }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -908,6 +1100,12 @@ "node.flow": "1.2.3" } }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, "sax": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", @@ -974,6 +1172,15 @@ "strip-ansi": "^4.0.0" } }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, "strip-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", @@ -995,6 +1202,24 @@ "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + } + }, "supports-color": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", @@ -1030,6 +1255,12 @@ "querystring": "0.2.0" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, "uuid": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", @@ -1113,12 +1344,12 @@ "dev": true }, "ws": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", - "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.0.0.tgz", + "integrity": "sha512-cknCal4k0EAOrh1SHHPPWWh4qm93g1IuGGGwBjWkXmCG7LsDtL8w9w+YVfaF+KSVwiHQKDIMsSLBVftKf9d1pg==", "dev": true, "requires": { - "async-limiter": "~1.0.0" + "async-limiter": "^1.0.0" } }, "xml2js": { diff --git a/manual_test_websocket/package.json b/manual_test_websocket/RouteSelection/package.json similarity index 76% rename from manual_test_websocket/package.json rename to manual_test_websocket/RouteSelection/package.json index a74701349..25cf8b82d 100644 --- a/manual_test_websocket/package.json +++ b/manual_test_websocket/RouteSelection/package.json @@ -13,12 +13,16 @@ "author": "", "license": "MIT", "dependencies": { - "aws-sdk": "^2.449.0" + "aws-sdk": "^2.482.0" }, "devDependencies": { + "aws4": "^1.8.0", + "awscred": "^1.4.2", "chai": "^4.2.0", + "chai-http": "^4.3.0", "mocha": "^6.1.4", + "moment": "^2.24.0", "serverless-dynamodb-local": "^0.2.37", - "ws": "^6.2.1" + "ws": "^7.0.0" } } diff --git a/manual_test_websocket/scripts/deploy_to_aws.sh b/manual_test_websocket/RouteSelection/scripts/deploy_to_aws.sh similarity index 68% rename from manual_test_websocket/scripts/deploy_to_aws.sh rename to manual_test_websocket/RouteSelection/scripts/deploy_to_aws.sh index 590bef430..fa4733de7 100755 --- a/manual_test_websocket/scripts/deploy_to_aws.sh +++ b/manual_test_websocket/RouteSelection/scripts/deploy_to_aws.sh @@ -1,10 +1,8 @@ #!/bin/bash echo "Deploying to AWS ..." -echo "Removing node modules ..." -rm -fr ./node_modules -echo "Instaing aws-sdk ..." -npm i aws-sdk +echo "Instaing node modules ..." +npm i echo "Copying serverless.yml ..." cp ./scripts/serverless..yml ./serverless.yml cat ./scripts/serverless.aws.yml >> ./serverless.yml diff --git a/manual_test_websocket/scripts/deploy_to_offline.sh b/manual_test_websocket/RouteSelection/scripts/deploy_to_offline.sh similarity index 80% rename from manual_test_websocket/scripts/deploy_to_offline.sh rename to manual_test_websocket/RouteSelection/scripts/deploy_to_offline.sh index adbb643a3..eb59d44b6 100755 --- a/manual_test_websocket/scripts/deploy_to_offline.sh +++ b/manual_test_websocket/RouteSelection/scripts/deploy_to_offline.sh @@ -1,14 +1,13 @@ #!/bin/bash echo "Deploying to Offline ..." -echo "Removing node modules ..." -rm -fr ./node_modules echo "Instaing node modules ..." -npm i +npm i echo "Linking serverless-offline ..." npm link serverless-offline echo "Copying serverless.yml ..." cp ./scripts/serverless..yml ./serverless.yml cat ./scripts/serverless.offline.yml >> ./serverless.yml echo "Deploying to Offline ..." -sls offline +npm start + diff --git a/manual_test_websocket/RouteSelection/scripts/serverless..yml b/manual_test_websocket/RouteSelection/scripts/serverless..yml new file mode 100644 index 000000000..dee745070 --- /dev/null +++ b/manual_test_websocket/RouteSelection/scripts/serverless..yml @@ -0,0 +1,28 @@ +# Welcome to Serverless! +# +# This file is the main config file for your service. +# It's very minimal at this point and uses default values. +# You can always add more config options for more control. +# We've included some commented out config examples here. +# Just uncomment any of them to get that config option. +# +# For full config options, check the docs: +# docs.serverless.com +# +# Happy Coding! + +service: manual-test-websocket-RouteSelection + +provider: + name: aws + runtime: nodejs10.x + websocketsApiRouteSelectionExpression: $request.body.service.do + + +functions: + echo: + handler: handler.echo + events: + - websocket: + route: echo + diff --git a/manual_test_websocket/scripts/serverless.aws.yml b/manual_test_websocket/RouteSelection/scripts/serverless.aws.yml similarity index 81% rename from manual_test_websocket/scripts/serverless.aws.yml rename to manual_test_websocket/RouteSelection/scripts/serverless.aws.yml index 64289757a..1844d0126 100644 --- a/manual_test_websocket/scripts/serverless.aws.yml +++ b/manual_test_websocket/RouteSelection/scripts/serverless.aws.yml @@ -6,4 +6,3 @@ package: - ./** include: - handler.js - - node_modules/** diff --git a/manual_test_websocket/RouteSelection/scripts/serverless.offline.yml b/manual_test_websocket/RouteSelection/scripts/serverless.offline.yml new file mode 100644 index 000000000..b3bcbfa71 --- /dev/null +++ b/manual_test_websocket/RouteSelection/scripts/serverless.offline.yml @@ -0,0 +1,7 @@ +plugins: + - serverless-offline + +custom: + serverless-offline: + port: 3004 + diff --git a/manual_test_websocket/serverless.yml b/manual_test_websocket/RouteSelection/serverless.yml.info similarity index 100% rename from manual_test_websocket/serverless.yml rename to manual_test_websocket/RouteSelection/serverless.yml.info diff --git a/manual_test_websocket/RouteSelection/test/e2e/ws.e2e.js b/manual_test_websocket/RouteSelection/test/e2e/ws.e2e.js new file mode 100644 index 000000000..ef07652ff --- /dev/null +++ b/manual_test_websocket/RouteSelection/test/e2e/ws.e2e.js @@ -0,0 +1,58 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const chai = require('chai'); + +const WebSocketTester = require('../support/WebSocketTester'); + +const expect = chai.expect; +const endpoint = process.env.npm_config_endpoint || 'ws://localhost:3005'; +const timeout = process.env.npm_config_timeout ? parseInt(process.env.npm_config_timeout) : 1000; + +describe('serverless', () => { + describe('with WebSocket support', () => { + let clients = []; + + const createWebSocket = async qs => { + const ws = new WebSocketTester(); + let url = endpoint; + + if (qs) url = `${endpoint}?${qs}`; + + await ws.open(url); + + clients.push(ws); + + return ws; + }; + + beforeEach(() => { + clients = []; + }); + + afterEach(async () => { + await Promise.all(clients.map(async (ws, i) => { + const n = ws.countUnrecived(); + + if (n > 0) { + console.log(`unreceived:[i=${i}]`); + (await ws.receive(n)).forEach(m => console.log(m)); + } + + expect(n).to.equal(0); + ws.close(); + })); + + clients = []; + }); + + it('should call action \'echo\' handler located at service.do', async () => { + const ws = await createWebSocket(); + const now = `${Date.now()}`; + const payload = JSON.stringify({ service:{ do:'echo' }, message:now }); + + ws.send(payload); + + expect(await ws.receive1()).to.equal(`${now}`); + }).timeout(timeout); + + }); +}); diff --git a/manual_test_websocket/RouteSelection/test/support/WebSocketTester.js b/manual_test_websocket/RouteSelection/test/support/WebSocketTester.js new file mode 100644 index 000000000..aaeff5a4a --- /dev/null +++ b/manual_test_websocket/RouteSelection/test/support/WebSocketTester.js @@ -0,0 +1,62 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const WebSocket = require('ws'); + +class WebSocketTester { + constructor() { + this.messages = []; this.receivers = []; + } + + open(url) { + if (this.ws != null) return; + const ws = this.ws = new WebSocket(url); + ws.on('message', message => { + // console.log('Received: '+message); + if (this.receivers.length > 0) this.receivers.shift()(message); + else this.messages.push(message); + }); + + return new Promise(resolve => { + ws.on('open', () => { + resolve(true); + }); + }); + } + + send(data) { + this.ws.send(data); + } + + receive1() { + return new Promise(resolve => { + if (this.messages.length > 0) resolve(this.messages.shift()); + else this.receivers.push(resolve); + }); + } + + receive(n) { + return new Promise(resolve => { + const messages = []; + for (let i = 0; i < n; i += 1) { + this.receive1().then(message => { + messages[i] = message; + if (i === n - 1) resolve(messages); + }); + } + }); + } + + skip() { + if (this.messages.length > 0) this.messages.shift(); + else this.receivers.push(() => {}); + } + + countUnrecived() { + return this.messages.length; + } + + close() { + if (this.ws != null) this.ws.close(); + } +} + +module.exports = WebSocketTester; diff --git a/manual_test_websocket/handler.js b/manual_test_websocket/handler.js deleted file mode 100644 index 74bc6f6ab..000000000 --- a/manual_test_websocket/handler.js +++ /dev/null @@ -1,99 +0,0 @@ -'use strict'; - -const AWS = require('aws-sdk'); -const ddb = (()=>{ - if (process.env.IS_OFFLINE) return new AWS.DynamoDB.DocumentClient({region: "localhost", endpoint: "http://localhost:8000"}); - return new AWS.DynamoDB.DocumentClient(); -})(); - - -const successfullResponse = { - statusCode: 200, - body: 'Request is OK.' -}; - -const errorResponse = { - statusCode: 400, - body: 'Request is not OK.' -}; - -module.exports.connect = async (event, context) => { - const listener=await ddb.get({TableName:'listeners', Key:{name:'default'}}).promise(); - if (listener.Item) await sendToClient(JSON.stringify({action:'update', event:'connect', info:{id:event.requestContext.connectionId}}), listener.Item.id, newAWSApiGatewayManagementApi(event, context)).catch(()=>{}); - return successfullResponse; -}; - -module.exports.disconnect = async (event, context) => { - const listener=await ddb.get({TableName:'listeners', Key:{name:'default'}}).promise(); - if (listener.Item) await sendToClient(JSON.stringify({action:'update', event:'disconnect', info:{id:event.requestContext.connectionId}}), listener.Item.id, newAWSApiGatewayManagementApi(event, context)).catch(()=>{}); - return successfullResponse; -}; - -module.exports.defaultHandler = async (event, context) => { - await sendToClient(`Error: No Supported Action in Payload '${event.body}'`, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err=>console.log(err)); - return successfullResponse; -}; - -module.exports.getClientInfo = async (event, context) => { - await sendToClient({action:'update', event:'client-info', info:{id:event.requestContext.connectionId}}, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err=>console.log(err)); - return successfullResponse; -}; - -module.exports.makeError = async (event, context) => { - const obj=null; - obj.non.non=1; - return successfullResponse; -}; - -module.exports.multiCall1 = async (event, context) => { - await sendToClient({action:'update', event:'made-call-1'}, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err=>console.log(err)); - return successfullResponse; -}; - -module.exports.multiCall2 = async (event, context) => { - await sendToClient({action:'update', event:'made-call-2'}, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err=>console.log(err)); - return successfullResponse; -}; - - -module.exports.send = async (event, context) => { - const action = JSON.parse(event.body); - const sents=[]; - action.clients.forEach((connectionId)=>{ - const sent=sendToClient(action.data, connectionId, newAWSApiGatewayManagementApi(event, context)); - sents.push(sent); - }); - const noErr=await Promise.all(sents).then(()=>true).catch(()=>false); - if (!noErr) await sendToClient('Error: Could not Send all Messages', event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)); - return successfullResponse; -}; - -module.exports.registerListener = async (event, context) => { - await ddb.put({TableName:'listeners', Item:{name:'default', id:event.requestContext.connectionId}}).promise(); - await sendToClient({action:'update', event:'register-listener', info:{id:event.requestContext.connectionId}}, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err=>console.log(err)); - return successfullResponse; -}; - -module.exports.deleteListener = async (event, context) => { - await ddb.delete({TableName:'listeners', Key:{name:'default'}}).promise(); - - return successfullResponse; -}; - -const newAWSApiGatewayManagementApi=(event, context)=>{ - const endpoint=event.requestContext.domainName+'/'+event.requestContext.stage; - const apiVersion='2018-11-29'; - let API=context.API; - if (!process.env.IS_OFFLINE) { - API = require('aws-sdk'); - require('aws-sdk/clients/apigatewaymanagementapi'); - } - return new API.ApiGatewayManagementApi({ apiVersion, endpoint }); -}; - -const sendToClient = (data, connectionId, apigwManagementApi) => { - let sendee=data; - if ('object'==typeof data) sendee=JSON.stringify(data); - - return apigwManagementApi.postToConnection({ConnectionId: connectionId, Data: sendee}).promise(); -}; diff --git a/manual_test_websocket/main/handler.js b/manual_test_websocket/main/handler.js new file mode 100644 index 000000000..58c4d7d47 --- /dev/null +++ b/manual_test_websocket/main/handler.js @@ -0,0 +1,131 @@ +const AWS = require('aws-sdk'); + +const ddb = (() => { + if (process.env.IS_OFFLINE) return new AWS.DynamoDB.DocumentClient({ region: 'localhost', endpoint: 'http://localhost:8000' }); + + return new AWS.DynamoDB.DocumentClient(); +})(); + +const successfullResponse = { + statusCode: 200, + body: 'Request is OK.', +}; + +module.exports.connect = async (event, context) => { + // console.log('connect:'); + const listener = await ddb.get({ TableName:'listeners', Key:{ name:'default' } }).promise(); + + if (listener.Item) { + const timeout = new Promise(resolve => setTimeout(resolve, 100)); + const send = sendToClient( // sendToClient won't return on AWS when client doesn't exits so we set a timeout + JSON.stringify({ action:'update', event:'connect', info:{ id:event.requestContext.connectionId, event:{ ...event, apiGatewayUrl:`${event.apiGatewayUrl}` }, context } }), + listener.Item.id, + newAWSApiGatewayManagementApi(event, context)).catch(() => {}); + await Promise.race([send, timeout]); + } + + return successfullResponse; +}; + +// module.export.auth = (event, context, callback) => { +// //console.log('auth:'); +// const token = event.headers["Authorization"]; + +// if ('deny'===token) callback(null, generatePolicy('user', 'Deny', event.methodArn)); +// else callback(null, generatePolicy('user', 'Allow', event.methodArn));; +// }; + +module.exports.disconnect = async (event, context) => { + const listener = await ddb.get({ TableName:'listeners', Key:{ name:'default' } }).promise(); + if (listener.Item) await sendToClient(JSON.stringify({ action:'update', event:'disconnect', info:{ id:event.requestContext.connectionId, event:{ ...event, apiGatewayUrl:`${event.apiGatewayUrl}` }, context } }), listener.Item.id, newAWSApiGatewayManagementApi(event, context)).catch(() => {}); + + return successfullResponse; +}; + +module.exports.defaultHandler = async (event, context) => { + await sendToClient(`Error: No Supported Action in Payload '${event.body}'`, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err => console.log(err)); + + return successfullResponse; +}; + +module.exports.getClientInfo = async (event, context) => { + // console.log('getClientInfo:'); + await sendToClient({ action:'update', event:'client-info', info:{ id:event.requestContext.connectionId } }, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err => console.log(err)); + + return successfullResponse; +}; + +module.exports.getCallInfo = async (event, context) => { + await sendToClient({ action:'update', event:'call-info', info:{ event:{ ...event, apiGatewayUrl:`${event.apiGatewayUrl}` }, context } }, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err => console.log(err)); + + return successfullResponse; +}; + +module.exports.makeError = async () => { + const obj = null; + obj.non.non = 1; + + return successfullResponse; +}; + +module.exports.replyViaCallback = (event, context, callback) => { + sendToClient({ action:'update', event:'reply-via-callback' }, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err => console.log(err)); + callback(); +}; + +module.exports.replyErrorViaCallback = (event, context, callback) => callback('error error error'); + +module.exports.multiCall1 = async (event, context) => { + await sendToClient({ action:'update', event:'made-call-1' }, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err => console.log(err)); + + return successfullResponse; +}; + +module.exports.multiCall2 = async (event, context) => { + await sendToClient({ action:'update', event:'made-call-2' }, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err => console.log(err)); + + return successfullResponse; +}; + +module.exports.send = async (event, context) => { + const action = JSON.parse(event.body); + const sents = []; + action.clients.forEach(connectionId => { + const sent = sendToClient(action.data, connectionId, newAWSApiGatewayManagementApi(event, context)); + sents.push(sent); + }); + const noErr = await Promise.all(sents).then(() => true).catch(() => false); + if (!noErr) await sendToClient('Error: Could not Send all Messages', event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)); + + return successfullResponse; +}; + +module.exports.registerListener = async (event, context) => { + await ddb.put({ TableName:'listeners', Item:{ name:'default', id:event.requestContext.connectionId } }).promise(); + await sendToClient({ action:'update', event:'register-listener', info:{ id:event.requestContext.connectionId } }, event.requestContext.connectionId, newAWSApiGatewayManagementApi(event, context)).catch(err => console.log(err)); + + return successfullResponse; +}; + +module.exports.deleteListener = async () => { + await ddb.delete({ TableName:'listeners', Key:{ name:'default' } }).promise(); + + return successfullResponse; +}; + +const newAWSApiGatewayManagementApi = event => { + let endpoint = event.apiGatewayUrl; + + if (!endpoint) endpoint = `${event.requestContext.domainName}/${event.requestContext.stage}`; + const apiVersion = '2018-11-29'; + + return new AWS.ApiGatewayManagementApi({ apiVersion, endpoint }); +}; + +const sendToClient = (data, connectionId, apigwManagementApi) => { + // console.log(`sendToClient:${connectionId}`); + let sendee = data; + if (typeof data === 'object') sendee = JSON.stringify(data); + + return apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: sendee }).promise(); +}; diff --git a/manual_test_websocket/main/package-lock.json b/manual_test_websocket/main/package-lock.json new file mode 100644 index 000000000..85c7bd691 --- /dev/null +++ b/manual_test_websocket/main/package-lock.json @@ -0,0 +1,1488 @@ +{ + "name": "manual_test", + "version": "0.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/chai": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.1.7.tgz", + "integrity": "sha512-2Y8uPt0/jwjhQ6EiluT0XCri1Dbplr0ZxfFXUz+ye13gaqE8u5gL5ppao1JrUYr9cIip5S6MvQzBS7Kke7U9VA==", + "dev": true + }, + "@types/cookiejar": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz", + "integrity": "sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw==", + "dev": true + }, + "@types/node": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.2.tgz", + "integrity": "sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA==", + "dev": true + }, + "@types/superagent": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-3.8.7.tgz", + "integrity": "sha512-9KhCkyXv268A2nZ1Wvu7rQWM+BmdYUVkycFeNnYrUL5Zwu7o8wPQ3wBfW59dDP+wuoxw0ww8YKgTNv8j/cgscA==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "ansi-colors": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", + "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "aws-sdk": { + "version": "2.481.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.481.0.tgz", + "integrity": "sha512-uwFGzwb2bKkh2KdX0nsebGqQNItZZ6j8+oL03jqSxCouO4FvFZpo8jd0ZnmkEeL6mWvv52WqV8HHhQNEyWkfNQ==", + "requires": { + "buffer": "4.9.1", + "events": "1.1.1", + "ieee754": "1.1.8", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.3.2", + "xml2js": "0.4.19" + } + }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", + "dev": true + }, + "awscred": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/awscred/-/awscred-1.4.2.tgz", + "integrity": "sha512-j3Vehf6PCFzzPZKkzEcj0Y2QO8w8UBbgobnl3DwHMiAE9A2mfJxTkq3cX4UNWHmrTAR0rj5BC/ts90Ok4Pg6rw==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "dev": true, + "requires": { + "inherits": "~2.0.0" + } + }, + "bluebird": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.4.tgz", + "integrity": "sha512-FG+nFEZChJrbQ9tIccIfZJBz3J7mLrAhxakAbnrJWn8d7aKOC+LWifa0G+p4ZqKp4y13T7juYvdhq9NzKdsrjw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "chai": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz", + "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.0", + "type-detect": "^4.0.5" + } + }, + "chai-http": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/chai-http/-/chai-http-4.3.0.tgz", + "integrity": "sha512-zFTxlN7HLMv+7+SPXZdkd5wUlK+KxH6Q7bIEMiEx0FK3zuuMqL7cwICAQ0V1+yYRozBburYuxN1qZstgHpFZQg==", + "dev": true, + "requires": { + "@types/chai": "4", + "@types/superagent": "^3.8.3", + "cookiejar": "^2.1.1", + "is-ip": "^2.0.0", + "methods": "^1.1.2", + "qs": "^6.5.1", + "superagent": "^3.7.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "cliui": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", + "integrity": "sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==", + "dev": true, + "requires": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "dynamodb-localhost": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/dynamodb-localhost/-/dynamodb-localhost-0.0.7.tgz", + "integrity": "sha512-Xyv0EqQDuOVjA8XGVOo3SuzQ5jKA8/gBKUeKRP3V586Fh9abWXLXOGjf7mPO8sWzddzGqyQx2mALj9IWSotg7A==", + "dev": true, + "requires": { + "mkdirp": "^0.5.0", + "progress": "^1.1.8", + "rmdir": "^1.2.0", + "tar": "^2.0.0" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "es-abstract": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz", + "integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.0", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "is-callable": "^1.1.4", + "is-regex": "^1.0.4", + "object-keys": "^1.0.12" + } + }, + "es-to-primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", + "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "flat": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.0.tgz", + "integrity": "sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw==", + "dev": true, + "requires": { + "is-buffer": "~2.0.3" + } + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fstream": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", + "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", + "dev": true + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "ieee754": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", + "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "ip-regex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", + "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", + "dev": true + }, + "is": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/is/-/is-0.2.7.tgz", + "integrity": "sha1-OzSixI81mXLzUEKEkZOucmS2NWI=", + "dev": true + }, + "is-buffer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==", + "dev": true + }, + "is-callable": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", + "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", + "dev": true + }, + "is-date-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-2.0.0.tgz", + "integrity": "sha1-aO6gfooKCpTC0IDdZ0xzGrKkYas=", + "dev": true, + "requires": { + "ip-regex": "^2.0.0" + } + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "dev": true, + "requires": { + "has": "^1.0.1" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-symbol": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", + "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + } + }, + "map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "requires": { + "p-defer": "^1.0.0" + } + }, + "mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + } + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "dev": true + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, + "requires": { + "mime-db": "1.40.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.1.4.tgz", + "integrity": "sha512-PN8CIy4RXsIoxoFJzS4QNnCH4psUCPWc4/rPrst/ecSJJbLBkubMiyGCP2Kj/9YnWbotFqAoeXyXMucj7gwCFg==", + "dev": true, + "requires": { + "ansi-colors": "3.2.3", + "browser-stdout": "1.3.1", + "debug": "3.2.6", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "find-up": "3.0.0", + "glob": "7.1.3", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "3.13.1", + "log-symbols": "2.2.0", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "ms": "2.1.1", + "node-environment-flags": "1.0.5", + "object.assign": "4.1.0", + "strip-json-comments": "2.0.1", + "supports-color": "6.0.0", + "which": "1.3.1", + "wide-align": "1.1.3", + "yargs": "13.2.2", + "yargs-parser": "13.0.0", + "yargs-unparser": "1.5.0" + } + }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-environment-flags": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.5.tgz", + "integrity": "sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ==", + "dev": true, + "requires": { + "object.getownpropertydescriptors": "^2.0.3", + "semver": "^5.7.0" + } + }, + "node.extend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-1.0.8.tgz", + "integrity": "sha1-urBDefc4P0WHmQyd8Htqf2Xbdys=", + "dev": true, + "requires": { + "is": "~0.2.6", + "object-keys": "~0.4.0" + }, + "dependencies": { + "object-keys": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz", + "integrity": "sha1-KKaq50KN0sOpLz2V8hM13SBOAzY=", + "dev": true + } + } + }, + "node.flow": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/node.flow/-/node.flow-1.2.3.tgz", + "integrity": "sha1-4cRKgq7KjXi0WKd/s9xkLy66Jkk=", + "dev": true, + "requires": { + "node.extend": "1.0.8" + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "object.getownpropertydescriptors": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", + "integrity": "sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.5.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=", + "dev": true + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", + "dev": true + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rmdir": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rmdir/-/rmdir-1.2.0.tgz", + "integrity": "sha1-T+A1fLBhaMJY5z6WgJPcTooPMlM=", + "dev": true, + "requires": { + "node.flow": "1.2.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "serverless-dynamodb-local": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/serverless-dynamodb-local/-/serverless-dynamodb-local-0.2.37.tgz", + "integrity": "sha512-1q3rfmn+Y1nZQhmuVdFLJcMTc6la1zY6sT98TS6Af0vRG//oa7CuiDEox0XEzuj7KZ0TodwmXmnRdzt9VRm3fA==", + "dev": true, + "requires": { + "aws-sdk": "^2.7.0", + "bluebird": "^3.4.6", + "dynamodb-localhost": "^0.0.7", + "lodash": "^4.17.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + } + }, + "supports-color": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", + "integrity": "sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "tar": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", + "dev": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.2", + "inherits": "2" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "ws": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.0.0.tgz", + "integrity": "sha512-cknCal4k0EAOrh1SHHPPWWh4qm93g1IuGGGwBjWkXmCG7LsDtL8w9w+YVfaF+KSVwiHQKDIMsSLBVftKf9d1pg==", + "dev": true, + "requires": { + "async-limiter": "^1.0.0" + } + }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yargs": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.2.2.tgz", + "integrity": "sha512-WyEoxgyTD3w5XRpAQNYUB9ycVH/PQrToaTXdYXRdOXvEy1l19br+VJsc0vcO8PTGg5ro/l/GY7F/JMEBmI0BxA==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "os-locale": "^3.1.0", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "yargs-parser": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.0.0.tgz", + "integrity": "sha512-w2LXjoL8oRdRQN+hOyppuXs+V/fVAYtpcrRxZuF7Kt/Oc+Jr2uAcVntaUTNT6w5ihoWfFDpNY8CPx1QskxZ/pw==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "yargs-unparser": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.5.0.tgz", + "integrity": "sha512-HK25qidFTCVuj/D1VfNiEndpLIeJN78aqgR23nL3y4N0U/91cOAzqfHlF8n2BvoNDcZmJKin3ddNSvOxSr8flw==", + "dev": true, + "requires": { + "flat": "^4.1.0", + "lodash": "^4.17.11", + "yargs": "^12.0.5" + }, + "dependencies": { + "get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "dev": true + }, + "yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + } + } +} diff --git a/manual_test_websocket/main/package.json b/manual_test_websocket/main/package.json new file mode 100644 index 000000000..86d946b13 --- /dev/null +++ b/manual_test_websocket/main/package.json @@ -0,0 +1,28 @@ +{ + "name": "manual_test", + "version": "0.0.0", + "description": "", + "main": "handler.js", + "scripts": { + "test": "mocha ./test/*", + "test-only": "mocha ", + "start": "sls offline", + "deploy-aws": "./scripts/deploy_to_aws.sh", + "deploy-offline": "./scripts/deploy_to_offline.sh" + }, + "author": "", + "license": "MIT", + "dependencies": { + "aws-sdk": "^2.481.0" + }, + "devDependencies": { + "aws4": "^1.8.0", + "awscred": "^1.4.2", + "chai": "^4.2.0", + "chai-http": "^4.3.0", + "mocha": "^6.1.4", + "moment": "^2.24.0", + "serverless-dynamodb-local": "^0.2.37", + "ws": "^7.0.0" + } +} diff --git a/manual_test_websocket/main/scripts/deploy_to_aws.sh b/manual_test_websocket/main/scripts/deploy_to_aws.sh new file mode 100755 index 000000000..fa4733de7 --- /dev/null +++ b/manual_test_websocket/main/scripts/deploy_to_aws.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +echo "Deploying to AWS ..." +echo "Instaing node modules ..." +npm i +echo "Copying serverless.yml ..." +cp ./scripts/serverless..yml ./serverless.yml +cat ./scripts/serverless.aws.yml >> ./serverless.yml +echo "Deploying to AWS ..." +sls deploy diff --git a/manual_test_websocket/main/scripts/deploy_to_offline.sh b/manual_test_websocket/main/scripts/deploy_to_offline.sh new file mode 100755 index 000000000..d5f8b4000 --- /dev/null +++ b/manual_test_websocket/main/scripts/deploy_to_offline.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +echo "Deploying to Offline ..." +echo "Instaing node modules ..." +npm i +echo "Linking serverless-offline ..." +npm link serverless-offline +echo "Copying serverless.yml ..." +cp ./scripts/serverless..yml ./serverless.yml +cat ./scripts/serverless.offline.yml >> ./serverless.yml +echo "Deploying to Offline ..." +npm start diff --git a/manual_test_websocket/scripts/serverless..yml b/manual_test_websocket/main/scripts/serverless..yml similarity index 78% rename from manual_test_websocket/scripts/serverless..yml rename to manual_test_websocket/main/scripts/serverless..yml index 07fb676c0..d04a1792f 100644 --- a/manual_test_websocket/scripts/serverless..yml +++ b/manual_test_websocket/main/scripts/serverless..yml @@ -11,11 +11,11 @@ # # Happy Coding! -service: manual-test-websocket +service: manual-test-websocket-main provider: name: aws - runtime: nodejs8.10 + runtime: nodejs10.x iamRoleStatements: - Effect: Allow @@ -35,11 +35,20 @@ provider: - "arn:aws:execute-api:*:*:**/@connections/*" functions: + # http: + # handler: handler.http + # events: + # - http: + # path: /http + # method: GET connect: handler: handler.connect events: - websocket: route: $connect + # authorizer: auth + # auth: + # handler: handler.auth disconnect: handler: handler.disconnect events: @@ -55,11 +64,26 @@ functions: events: - websocket: route: getClientInfo + getCallInfo: + handler: handler.getCallInfo + events: + - websocket: + route: getCallInfo makeError: handler: handler.makeError events: - websocket: route: makeError + replyViaCallback: + handler: handler.replyViaCallback + events: + - websocket: + route: replyViaCallback + replyErrorViaCallback: + handler: handler.replyErrorViaCallback + events: + - websocket: + route: replyErrorViaCallback multiCall1: handler: handler.multiCall1 events: diff --git a/manual_test_websocket/main/scripts/serverless.aws.yml b/manual_test_websocket/main/scripts/serverless.aws.yml new file mode 100644 index 000000000..1844d0126 --- /dev/null +++ b/manual_test_websocket/main/scripts/serverless.aws.yml @@ -0,0 +1,8 @@ +plugins: + # - serverless-offline + +package: + exclude: + - ./** + include: + - handler.js diff --git a/manual_test_websocket/scripts/serverless.offline.yml b/manual_test_websocket/main/scripts/serverless.offline.yml similarity index 100% rename from manual_test_websocket/scripts/serverless.offline.yml rename to manual_test_websocket/main/scripts/serverless.offline.yml diff --git a/manual_test_websocket/main/serverless.yml.info b/manual_test_websocket/main/serverless.yml.info new file mode 100644 index 000000000..596836955 --- /dev/null +++ b/manual_test_websocket/main/serverless.yml.info @@ -0,0 +1,7 @@ +###################################### +### DO NOT EDIT THIS FILE DIRECTLY ### +### ### +### User either: ### +### 'npm run deploy-offline' or ### +### 'npm run deploy-aws' ### +###################################### \ No newline at end of file diff --git a/manual_test_websocket/main/test/e2e/ws.e2e.js b/manual_test_websocket/main/test/e2e/ws.e2e.js new file mode 100644 index 000000000..22bca2728 --- /dev/null +++ b/manual_test_websocket/main/test/e2e/ws.e2e.js @@ -0,0 +1,398 @@ +/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable import/no-unresolved */ +/* eslint-disable no-unused-expressions */ +const chai = require('chai'); +const chaiHttp = require('chai-http'); + +chai.use(chaiHttp); +const expect = chai.expect; +const aws4 = require('aws4'); + +const awscred = require('awscred'); +const moment = require('moment'); + +const endpoint = process.env.npm_config_endpoint || 'ws://localhost:3001'; +const timeout = process.env.npm_config_timeout ? parseInt(process.env.npm_config_timeout) : 1000; +const WebSocketTester = require('../support/WebSocketTester'); + +describe('serverless', () => { + describe('with WebSocket support', () => { + let clients = []; let req = null; let cred = null; + const createWebSocket = async qs => { + const ws = new WebSocketTester(); + let url = endpoint; + + if (qs) url = `${endpoint}?${qs}`; + + await ws.open(url); + + clients.push(ws); + + return ws; + }; + const createClient = async qs => { + const ws = await createWebSocket(qs); + + ws.send(JSON.stringify({ action:'getClientInfo' })); + + const json = await ws.receive1(); + const id = JSON.parse(json).info.id; + + return { ws, id }; + }; + before(async () => { + req = chai + .request(`${endpoint.replace('ws://', 'http://') + .replace('wss://', 'https://')}`) + .keepOpen(); + + cred = await new Promise((resolve, reject) => { + awscred.loadCredentials((err, data) => { + if (err) reject(err); else resolve(data); + }); + }); + }); + + beforeEach(() => { + clients = []; + }); + afterEach(async () => { + await Promise.all(clients.map(async (ws, i) => { + const n = ws.countUnrecived(); + + if (n > 0) { + console.log(`unreceived:[i=${i}]`); + (await ws.receive(n)).forEach(m => console.log(m)); + } + + expect(n).to.equal(0); + ws.close(); + })); + clients = []; + }); + + it('should request to upgade to WebSocket when receving an HTTP request', async () => { + const req = chai.request(`${endpoint.replace('ws://', 'http://').replace('wss://', 'https://')}`).keepOpen(); + let res = await req.get(`/${Date.now()}`);// .set('Authorization', user.accessToken); + + expect(res).to.have.status(426); + + res = await req.get(`/${Date.now()}/${Date.now()}`);// .set('Authorization', user.accessToken); + + expect(res).to.have.status(426); + }).timeout(timeout); + + it('should open a WebSocket', async () => { + const ws = await createWebSocket(); + expect(ws).not.to.be.undefined; + }).timeout(timeout); + + it('should receive client connection info', async () => { + const ws = await createWebSocket(); + ws.send(JSON.stringify({ action:'getClientInfo' })); + const clientInfo = JSON.parse(await ws.receive1()); + + expect(clientInfo).to.deep.equal({ action:'update', event:'client-info', info:{ id:clientInfo.info.id } }); + }).timeout(timeout); + + it('should call default handler when no such action exists', async () => { + const ws = await createWebSocket(); + const payload = JSON.stringify({ action:`action${Date.now()}` }); + ws.send(payload); + + expect(await ws.receive1()).to.equal(`Error: No Supported Action in Payload '${payload}'`); + }).timeout(timeout); + + it('should call default handler when no action provided', async () => { + const ws = await createWebSocket(); + ws.send(JSON.stringify({ hello:'world' })); + + expect(await ws.receive1()).to.equal('Error: No Supported Action in Payload \'{"hello":"world"}\''); + }).timeout(timeout); + + it('should send & receive data', async () => { + const c1 = await createClient(); + const c2 = await createClient(); + c1.ws.send(JSON.stringify({ action:'send', data:'Hello World!', clients:[c1.id, c2.id] })); + + expect(await c1.ws.receive1()).to.equal('Hello World!'); + expect(await c2.ws.receive1()).to.equal('Hello World!'); + }).timeout(timeout); + + it('should respond when having an internal server error', async () => { + const conn = await createClient(); + conn.ws.send(JSON.stringify({ action:'makeError' })); + const res = JSON.parse(await conn.ws.receive1()); + + expect(res).to.deep.equal({ message:'Internal server error', connectionId:conn.id, requestId:res.requestId }); + }).timeout(timeout); + + it('should respond via callback', async () => { + const ws = await createWebSocket(); + ws.send(JSON.stringify({ action:'replyViaCallback' })); + const res = JSON.parse(await ws.receive1()); + expect(res).to.deep.equal({ action:'update', event:'reply-via-callback' }); + }).timeout(timeout); + + it('should respond with error when calling callback(error)', async () => { + const conn = await createClient(); + conn.ws.send(JSON.stringify({ action:'replyErrorViaCallback' })); + const res = JSON.parse(await conn.ws.receive1()); + expect(res).to.deep.equal({ message:'Internal server error', connectionId:conn.id, requestId:res.requestId }); + }).timeout(timeout); + + it('should respond with only the last action when there are more than one in the serverless.yml file', async () => { + const ws = await createWebSocket(); + ws.send(JSON.stringify({ action:'makeMultiCalls' })); + const res = JSON.parse(await ws.receive1()); + + expect(res).to.deep.equal({ action:'update', event:'made-call-2' }); + }).timeout(timeout); + + it('should not send to non existing client', async () => { + const c1 = await createClient(); + c1.ws.send(JSON.stringify({ action:'send', data:'Hello World!', clients:['non-existing-id'] })); + + expect(await c1.ws.receive1()).to.equal('Error: Could not Send all Messages'); + }).timeout(timeout); + + it('should connect & disconnect', async () => { + const ws = await createWebSocket(); + await ws.send(JSON.stringify({ action:'registerListener' })); + await ws.receive1(); + + const c1 = await createClient(); + const connect1 = JSON.parse(await ws.receive1()); delete connect1.info.event; delete delete connect1.info.context; + expect(connect1).to.deep.equal({ action:'update', event:'connect', info:{ id:c1.id } }); + + const c2 = await createClient(); + const connect2 = JSON.parse(await ws.receive1()); delete connect2.info.event; delete delete connect2.info.context; + expect(connect2).to.deep.equal({ action:'update', event:'connect', info:{ id:c2.id } }); + + c2.ws.close(); + const disconnect2 = JSON.parse(await ws.receive1()); delete disconnect2.info.event; delete delete disconnect2.info.context; + expect(disconnect2).to.deep.equal({ action:'update', event:'disconnect', info:{ id:c2.id } }); + + const c3 = await createClient(); + const connect3 = JSON.parse(await ws.receive1()); delete connect3.info.event; delete delete connect3.info.context; + expect(connect3).to.deep.equal({ action:'update', event:'connect', info:{ id:c3.id } }); + + c1.ws.close(); + const disconnect1 = JSON.parse(await ws.receive1()); delete disconnect1.info.event; delete delete disconnect1.info.context; + expect(disconnect1).to.deep.equal({ action:'update', event:'disconnect', info:{ id:c1.id } }); + + c3.ws.close(); + const disconnect3 = JSON.parse(await ws.receive1()); delete disconnect3.info.event; delete delete disconnect3.info.context; + expect(disconnect3).to.deep.equal({ action:'update', event:'disconnect', info:{ id:c3.id } }); + }).timeout(timeout); + + const createExpectedEvent = (connectionId, action, eventType, actualEvent) => { + const url = new URL(endpoint); + const expected = { + apiGatewayUrl: `${actualEvent.apiGatewayUrl}`, + isBase64Encoded: false, + requestContext: { + apiId: actualEvent.requestContext.apiId, + connectedAt: actualEvent.requestContext.connectedAt, + connectionId: `${connectionId}`, + domainName: url.hostname, + eventType, + extendedRequestId: actualEvent.requestContext.extendedRequestId, + identity: { + accessKey: null, + accountId: null, + caller: null, + cognitoAuthenticationProvider: null, + cognitoAuthenticationType: null, + cognitoIdentityId: null, + cognitoIdentityPoolId: null, + principalOrgId: null, + sourceIp: actualEvent.requestContext.identity.sourceIp, + user: null, + userAgent: null, + userArn: null, + }, + messageDirection: 'IN', + messageId: actualEvent.requestContext.messageId, + requestId: actualEvent.requestContext.requestId, + requestTime: actualEvent.requestContext.requestTime, + requestTimeEpoch: actualEvent.requestContext.requestTimeEpoch, + routeKey: action, + stage: actualEvent.requestContext.stage, + }, + }; + + return expected; + }; + + const createExpectedContext = actualContext => { + const expected = { + awsRequestId: actualContext.awsRequestId, + callbackWaitsForEmptyEventLoop: true, + functionName: actualContext.functionName, + functionVersion: '$LATEST', + invokedFunctionArn: actualContext.invokedFunctionArn, + invokeid: actualContext.invokeid, + logGroupName: actualContext.logGroupName, + logStreamName: actualContext.logStreamName, + memoryLimitInMB: actualContext.memoryLimitInMB, + }; + + return expected; + }; + + const createExpectedConnectHeaders = actualHeaders => { + const url = new URL(endpoint); + const expected = { + Host: url.hostname, + 'Sec-WebSocket-Extensions': actualHeaders['Sec-WebSocket-Extensions'], + 'Sec-WebSocket-Key': actualHeaders['Sec-WebSocket-Key'], + 'Sec-WebSocket-Version': actualHeaders['Sec-WebSocket-Version'], + 'X-Amzn-Trace-Id': actualHeaders['X-Amzn-Trace-Id'], + 'X-Forwarded-For': actualHeaders['X-Forwarded-For'], + 'X-Forwarded-Port': `${url.port || 443}`, + 'X-Forwarded-Proto': `${url.protocol.replace('ws', 'http').replace('wss', 'https').replace(':', '')}`, + }; + + return expected; + }; + + const createExpectedDisconnectHeaders = () => { + const url = new URL(endpoint); + const expected = { + Host: url.hostname, + 'x-api-key': '', + 'x-restapi': '', + }; + + return expected; + }; + + const createExpectedConnectMultiValueHeaders = actualHeaders => { + const expected = createExpectedConnectHeaders(actualHeaders); + Object.keys(expected).forEach(key => { + expected[key] = [expected[key]]; + }); + + return expected; + }; + + const createExpectedDisconnectMultiValueHeaders = actualHeaders => { + const expected = createExpectedDisconnectHeaders(actualHeaders); + Object.keys(expected).forEach(key => { + expected[key] = [expected[key]]; + }); + + return expected; + }; + + it('should receive correct call info (event only)', async () => { + const ws = await createWebSocket(); + await ws.send(JSON.stringify({ action:'registerListener' })); + await ws.receive1(); + + // connect + const c = await createClient(); + const connect = JSON.parse(await ws.receive1()); + let now = Date.now(); + let expectedCallInfo = { id:c.id, event:{ headers:createExpectedConnectHeaders(connect.info.event.headers), multiValueHeaders:createExpectedConnectMultiValueHeaders(connect.info.event.headers), ...createExpectedEvent(c.id, '$connect', 'CONNECT', connect.info.event) }, context:createExpectedContext(connect.info.context) }; + delete connect.info.context; delete expectedCallInfo.context; // Not checking context. Relying on it to be correct because serverless-offline uses general lambda context method + + expect(connect).to.deep.equal({ action:'update', event:'connect', info:expectedCallInfo }); + expect(connect.info.event.requestContext.requestTimeEpoch).to.be.within(connect.info.event.requestContext.connectedAt - 10, connect.info.event.requestContext.requestTimeEpoch + 10); + expect(connect.info.event.requestContext.connectedAt).to.be.within(now - timeout, now); + expect(connect.info.event.requestContext.requestTimeEpoch).to.be.within(now - timeout, now); + expect(moment.utc(connect.info.event.requestContext.requestTime, 'D/MMM/YYYY:H:m:s Z').toDate().getTime()).to.be.within(now - timeout, now); + + if (endpoint.startsWith('ws://locahost')) { + expect(connect.info.event.apiGatewayUrl).to.equal(endpoint.replace('ws://', 'http://').replace('wss://', 'https://')); + expect(connect.info.event.headers['X-Forwarded-For']).to.be.equal('127.0.0.1'); + } + + // getCallInfo + c.ws.send(JSON.stringify({ action:'getCallInfo' })); + const callInfo = JSON.parse(await c.ws.receive1()); + now = Date.now(); + expectedCallInfo = { event:{ body: '{"action":"getCallInfo"}', ...createExpectedEvent(c.id, 'getCallInfo', 'MESSAGE', callInfo.info.event) }, context:createExpectedContext(callInfo.info.context) }; + delete callInfo.info.context; delete expectedCallInfo.context; // Not checking context. Relying on it to be correct because serverless-offline uses general lambda context method + + expect(callInfo).to.deep.equal({ action:'update', event:'call-info', info:expectedCallInfo }); + expect(callInfo.info.event.requestContext.connectedAt).to.be.lt(callInfo.info.event.requestContext.requestTimeEpoch); + expect(callInfo.info.event.requestContext.connectedAt).to.be.within(now - timeout, now); + expect(callInfo.info.event.requestContext.requestTimeEpoch).to.be.within(now - timeout, now); + expect(moment.utc(callInfo.info.event.requestContext.requestTime, 'D/MMM/YYYY:H:m:s Z').toDate().getTime()).to.be.within(now - timeout, now); + if (endpoint.startsWith('ws://locahost')) expect(callInfo.info.event.apiGatewayUrl).to.equal(endpoint.replace('ws://', 'http://').replace('wss://', 'https://')); + + // disconnect + c.ws.close(); + const disconnect = JSON.parse(await ws.receive1()); + now = Date.now(); + expectedCallInfo = { id:c.id, event:{ headers:createExpectedDisconnectHeaders(disconnect.info.event.headers), multiValueHeaders:createExpectedDisconnectMultiValueHeaders(disconnect.info.event.headers), ...createExpectedEvent(c.id, '$disconnect', 'DISCONNECT', disconnect.info.event) }, context:createExpectedContext(disconnect.info.context) }; + delete disconnect.info.context; delete expectedCallInfo.context; // Not checking context. Relying on it to be correct because serverless-offline uses general lambda context method + expect(disconnect).to.deep.equal({ action:'update', event:'disconnect', info:expectedCallInfo }); + }).timeout(timeout); + + it('should be able to parse query string', async () => { + const now = `${Date.now()}`; + const ws = await createWebSocket(); + await ws.send(JSON.stringify({ action:'registerListener' })); + await ws.receive1(); + + await createClient(); + await createClient(`now=${now}&before=123456789`); + + expect(JSON.parse(await ws.receive1()).info.event.queryStringParameters).to.be.undefined; + expect(JSON.parse(await ws.receive1()).info.event.queryStringParameters).to.deep.equal({ now, before:'123456789' }); + }).timeout(timeout); + + it('should be able to receive messages via REST API', async () => { + await createClient(); + const c2 = await createClient(); + const url = new URL(endpoint); + const signature = { service: 'execute-api', host:url.host, path:`${url.pathname}/@connections/${c2.id}`, method: 'POST', body:'Hello World!', headers:{ 'Content-Type':'text/plain'/* 'application/text' */ } }; + aws4.sign(signature, { accessKeyId: cred.accessKeyId, secretAccessKey: cred.secretAccessKey }); + const res = await req.post(signature.path.replace(url.pathname, '')).set('X-Amz-Date', signature.headers['X-Amz-Date']).set('Authorization', signature.headers.Authorization).set('Content-Type', signature.headers['Content-Type']) +.send('Hello World!'); + + expect(res).to.have.status(200); + expect(await c2.ws.receive1()).to.equal('Hello World!'); + }).timeout(timeout); + + it('should receive error code when sending to a recently closed client via REST API', async () => { + const c = await createClient(); + const cId = c.id; + c.ws.close(); + const url = new URL(endpoint); + const signature = { service: 'execute-api', host:url.host, path:`${url.pathname}/@connections/${cId}`, method: 'POST', body:'Hello World!', headers:{ 'Content-Type':'text/plain'/* 'application/text' */ } }; + aws4.sign(signature, { accessKeyId: cred.accessKeyId, secretAccessKey: cred.secretAccessKey }); + const res = await req.post(signature.path.replace(url.pathname, '')).set('X-Amz-Date', signature.headers['X-Amz-Date']).set('Authorization', signature.headers.Authorization).set('Content-Type', signature.headers['Content-Type']) +.send('Hello World!'); + + expect(res).to.have.status(410); + }).timeout(timeout); + + it('should be able to close connections via REST API', async () => { + await createClient(); + const c2 = await createClient(); + const url = new URL(endpoint); + const signature = { service: 'execute-api', host: url.host, path: `${url.pathname}/@connections/${c2.id}`, method: 'DELETE' }; + aws4.sign(signature, { accessKeyId: cred.accessKeyId, secretAccessKey: cred.secretAccessKey }); + const res = await req.del(signature.path.replace(url.pathname, '')).set('X-Amz-Date', signature.headers['X-Amz-Date']).set('Authorization', signature.headers.Authorization); + + expect(res).to.have.status(200); + }).timeout(timeout); + + it('should receive error code when deleting a previously closed client via REST API', async () => { + const c = await createClient(); + const cId = c.id; + c.ws.close(); + const url = new URL(endpoint); + const signature = { service: 'execute-api', host: url.host, path: `${url.pathname}/@connections/${cId}`, method: 'DELETE' }; + aws4.sign(signature, { accessKeyId: cred.accessKeyId, secretAccessKey: cred.secretAccessKey }); + const res = await req.del(signature.path.replace(url.pathname, '')).set('X-Amz-Date', signature.headers['X-Amz-Date']).set('Authorization', signature.headers.Authorization); + + expect(res).to.have.status(410); + }).timeout(timeout); + + }); +}); diff --git a/manual_test_websocket/main/test/support/WebSocketTester.js b/manual_test_websocket/main/test/support/WebSocketTester.js new file mode 100644 index 000000000..aaeff5a4a --- /dev/null +++ b/manual_test_websocket/main/test/support/WebSocketTester.js @@ -0,0 +1,62 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const WebSocket = require('ws'); + +class WebSocketTester { + constructor() { + this.messages = []; this.receivers = []; + } + + open(url) { + if (this.ws != null) return; + const ws = this.ws = new WebSocket(url); + ws.on('message', message => { + // console.log('Received: '+message); + if (this.receivers.length > 0) this.receivers.shift()(message); + else this.messages.push(message); + }); + + return new Promise(resolve => { + ws.on('open', () => { + resolve(true); + }); + }); + } + + send(data) { + this.ws.send(data); + } + + receive1() { + return new Promise(resolve => { + if (this.messages.length > 0) resolve(this.messages.shift()); + else this.receivers.push(resolve); + }); + } + + receive(n) { + return new Promise(resolve => { + const messages = []; + for (let i = 0; i < n; i += 1) { + this.receive1().then(message => { + messages[i] = message; + if (i === n - 1) resolve(messages); + }); + } + }); + } + + skip() { + if (this.messages.length > 0) this.messages.shift(); + else this.receivers.push(() => {}); + } + + countUnrecived() { + return this.messages.length; + } + + close() { + if (this.ws != null) this.ws.close(); + } +} + +module.exports = WebSocketTester; diff --git a/manual_test_websocket/test/e2e/ws.e2e.js b/manual_test_websocket/test/e2e/ws.e2e.js deleted file mode 100644 index 718831e9f..000000000 --- a/manual_test_websocket/test/e2e/ws.e2e.js +++ /dev/null @@ -1,122 +0,0 @@ -'use strict'; - -const chai = require('chai'); -const expect = chai.expect; -const endpoint=process.env.npm_config_endpoint||'ws://localhost:3000/dev'; -const timeout=6000; - -const WebSocketTester=require('../support/WebSocketTester'); - -describe('serverless', ()=>{ - describe('with WebSocket support', ()=>{ - let clients=[]; - const createWebSocket=async ()=>{ - const ws=new WebSocketTester(); - await ws.open(endpoint); - clients.push(ws); - return ws; - }; - const createClient=async ()=>{ - const ws=await createWebSocket(); - ws.send(JSON.stringify({action:'getClientInfo'})); - const json=await ws.receive1(); - const id=JSON.parse(json).info.id; - return {ws, id}; - }; - - beforeEach(()=>{ - clients=[]; - }); - afterEach(async ()=>{ - await Promise.all(clients.map(async (ws, i)=>{ - const n=ws.countUnrecived(); - - if (n>0) { - console.log(`unreceived:[i=${i}]`); - (await ws.receive(n)).forEach(m=>console.log(m)); - } - expect(n).to.equal(0); - ws.close(); - })); - clients=[]; - }); - - it('should open a WebSocket', async ()=>{ - const ws=await createWebSocket(); - expect(ws).not.to.be.undefined; - }); - - it('should receive client connection info', async ()=>{ - const ws=await createWebSocket(); - ws.send(JSON.stringify({action:'getClientInfo'})); - const clientInfo=JSON.parse(await ws.receive1()); - expect(clientInfo).to.deep.equal({action:'update', event:'client-info', info:{id:clientInfo.info.id}}); - }); - - it('should call default handler when no such action exists', async ()=>{ - const ws=await createWebSocket(); - const payload=JSON.stringify({action:'action'+Date.now()}); - ws.send(payload); - expect(await ws.receive1()).to.equal(`Error: No Supported Action in Payload '${payload}'`); - }); - - it('should call default handler when no action provided', async ()=>{ - const ws=await createWebSocket(); - ws.send(JSON.stringify({hello:'world'})); - expect(await ws.receive1()).to.equal(`Error: No Supported Action in Payload '{"hello":"world"}'`); - }); - - it('should send & receive data', async ()=>{ - const c1=await createClient(); - const c2=await createClient(); - const c3=await createClient(); - c1.ws.send(JSON.stringify({action:'send', data:'Hello World!', clients:[c1.id, c3.id]})); - expect(await c1.ws.receive1()).to.equal('Hello World!'); - expect(await c3.ws.receive1()).to.equal('Hello World!'); - }).timeout(timeout); - - it('should response when having an internal server error', async ()=>{ - const conn=await createClient(); - conn.ws.send(JSON.stringify({action:'makeError'})); - const res=JSON.parse(await conn.ws.receive1()); - expect(res).to.deep.equal({message:'Internal server error', connectionId:conn.id, requestId:res.requestId}); - }); - - it('should response with only the last action when there are more than one in the serverless.yml file', async ()=>{ - const ws=await createWebSocket(); - ws.send(JSON.stringify({action:'makeMultiCalls'})); - const res=JSON.parse(await ws.receive1()); - expect(res).to.deep.equal({action:'update', event:'made-call-2'}); - }); - - it('should not send to non existing client', async ()=>{ - const c1=await createClient(); - c1.ws.send(JSON.stringify({action:'send', data:'Hello World!', clients:["non-existing-id"]})); - expect(await c1.ws.receive1()).to.equal('Error: Could not Send all Messages'); - }); - - it('should connect & disconnect', async ()=>{ - const ws=await createWebSocket(); - await ws.send(JSON.stringify({action:'registerListener'})); - await ws.receive1(); - - const c1=await createClient(); - expect(JSON.parse(await ws.receive1())).to.deep.equal({action:'update', event:'connect', info:{id:c1.id}}); - - const c2=await createClient(); - expect(JSON.parse(await ws.receive1())).to.deep.equal({action:'update', event:'connect', info:{id:c2.id}}); - - c2.ws.close(); - expect(JSON.parse(await ws.receive1())).to.deep.equal({action:'update', event:'disconnect', info:{id:c2.id}}); - - const c3=await createClient(); - expect(JSON.parse(await ws.receive1())).to.deep.equal({action:'update', event:'connect', info:{id:c3.id}}); - - c1.ws.close(); - expect(JSON.parse(await ws.receive1())).to.deep.equal({action:'update', event:'disconnect', info:{id:c1.id}}); - - c3.ws.close(); - expect(JSON.parse(await ws.receive1())).to.deep.equal({action:'update', event:'disconnect', info:{id:c3.id}}); - }).timeout(6000); - }); -}); diff --git a/manual_test_websocket/test/support/WebSocketTester.js b/manual_test_websocket/test/support/WebSocketTester.js deleted file mode 100644 index d5e80057a..000000000 --- a/manual_test_websocket/test/support/WebSocketTester.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict'; - -const WebSocket = require('ws'); - -class WebSocketTester { - constructor() { - this.messages=[]; this.receivers=[]; - } - - open(url) { - if (null!=this.ws) return; - const ws=this.ws=new WebSocket(url); - ws.on('message', (message)=>{ - // console.log('Received: '+message); - if (0 { - ws.on('open', ()=>{ - resolve(true); - }); - }); - } - - send(data) { - this.ws.send(data); - } - - receive1() { - return new Promise((resolve/*, reject*/)=>{ - if (0{ - const messages=[]; - for (let i=0; i{ - messages[i]=message; - if (i===n-1) resolve(messages); - }); - } - }); - } - - skip() { - if (0{}); - } - - countUnrecived() { - return this.messages.length; - } - - close() { - if (null!=this.ws) this.ws.close(); - } -}; - -module.exports=WebSocketTester; diff --git a/package-lock.json b/package-lock.json index 74d8712dd..8d1b65f00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -816,6 +816,11 @@ "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1095,6 +1100,11 @@ "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==", "dev": true }, + "bignumber.js": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-8.1.1.tgz", + "integrity": "sha512-QD46ppGintwPGuL1KqmwhR0O+N2cZUg8JG/VzwI2e28sM9TqHjQB10lI4QAaMHVbLzwVLLAwEglpKPViWX+5NQ==" + }, "bl": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", @@ -1379,6 +1389,22 @@ "url-to-options": "^1.0.1" } }, + "cbor": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/cbor/-/cbor-4.1.4.tgz", + "integrity": "sha512-SqNWyQnnYtKAPLA7lupvuGKrEgoF2rR/7I9rXdmW/9uxtmKdltthHTf8hfLLN1SIkoAFwz/jb6+VZuaHv3Lv6Q==", + "requires": { + "bignumber.js": "^8.0.1", + "commander": "^2.19.0", + "json-text-sequence": "^0.1", + "nofilter": "^1.0.1" + } + }, + "cbor-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cbor-js/-/cbor-js-0.1.0.tgz", + "integrity": "sha1-yAzmEg84fo+qdDcN/aIdlluPx/k=" + }, "chai": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz", @@ -1595,8 +1621,7 @@ "commander": { "version": "2.20.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", - "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", - "dev": true + "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==" }, "component-emitter": { "version": "1.3.0", @@ -1788,6 +1813,11 @@ "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", "dev": true }, + "cuid": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/cuid/-/cuid-2.1.6.tgz", + "integrity": "sha512-ZFp7PS6cSYMJNch9fc3tyHdE4T8TDo3Y5qAxb0KSA9mpiYDo7z9ql1CznFuuzxea9STVIDy0tJWm2lYiX2ZU1Q==" + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -1965,6 +1995,11 @@ "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true }, + "delimit-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/delimit-stream/-/delimit-stream-0.1.0.tgz", + "integrity": "sha1-m4MZR3wOX4rrPONXrjBfwl6hzSs=" + }, "depcheck": { "version": "0.6.11", "resolved": "https://registry.npmjs.org/depcheck/-/depcheck-0.6.11.tgz", @@ -2267,6 +2302,17 @@ "iconv-lite": "~0.4.13" } }, + "encodr": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/encodr/-/encodr-1.2.0.tgz", + "integrity": "sha512-OHAfuXxoeXEeXFZ0Vu3CGegIVI1iuLLdVMy1EIVDBfvff1tMjVwRNBFuo5UbjBm3Efcu+GiIYGOt0H3NKDjPrw==", + "requires": { + "cbor": "4.1.4", + "cbor-js": "0.1.0", + "msgpack-lite": "0.1.26", + "utf8": "3.0.0" + } + }, "end-of-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", @@ -2645,6 +2691,16 @@ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", "dev": true }, + "event-lite": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/event-lite/-/event-lite-0.1.2.tgz", + "integrity": "sha512-HnSYx1BsJ87/p6swwzv+2v6B4X+uxUteoDfRxsAb1S1BePzQqOLevVmkdA15GHJVd9A9Ok6wygUR18Hu0YeV9g==" + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, "events": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", @@ -3214,6 +3270,25 @@ "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, + "hapi-plugin-websocket": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/hapi-plugin-websocket/-/hapi-plugin-websocket-2.1.2.tgz", + "integrity": "sha512-QqpBaWJPeAq/uWd3pNt73hoavMiJJgW8agOBPMlYuT3qowrgnVWSZeAd5Gtv+7kG/Yk6cV0Vg26RBMNohe0jBA==", + "requires": { + "@hapi/boom": "7.4.2", + "@hapi/hoek": "7.1.0", + "urijs": "1.19.1", + "websocket-framed": "1.2.1", + "ws": "7.0.0" + }, + "dependencies": { + "@hapi/hoek": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-7.1.0.tgz", + "integrity": "sha512-jBTPzWrWQAizq7naLVwU+P2+TzVY3ZtPSX+F9gwW23ihwpihpYKvjN21zHKUjaePYS9ijlDF3oFVNbGfhbbk2w==" + } + } + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -3405,8 +3480,7 @@ "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", - "dev": true + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, "ignore": { "version": "4.0.6", @@ -3511,6 +3585,11 @@ } } }, + "int64-buffer": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-0.1.10.tgz", + "integrity": "sha1-J3siiofZWtd30HwTgyAiQGpHNCM=" + }, "invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -3786,8 +3865,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", @@ -3910,6 +3988,14 @@ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "dev": true }, + "json-text-sequence": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/json-text-sequence/-/json-text-sequence-0.1.1.tgz", + "integrity": "sha1-py8hfcSvxGKf/1/rME3BvVGi89I=", + "requires": { + "delimit-stream": "0.1.0" + } + }, "jsonata": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-1.6.5.tgz", @@ -4233,6 +4319,11 @@ "integrity": "sha1-mgD3bco26yP6BTUK/htYXUKZ5ks=", "dev": true }, + "luxon": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.16.0.tgz", + "integrity": "sha512-qaqB+JwpGwtl7UbIXng3A/l4W/ySBr8drQvwtMLZBMiLD2V+0fEnPWMrs+UjnIy9PsktazQaKvwDUCLzoWz0Hw==" + }, "make-dir": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", @@ -4607,6 +4698,17 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "msgpack-lite": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/msgpack-lite/-/msgpack-lite-0.1.26.tgz", + "integrity": "sha1-3TxQsm8FnyXn7e42REGDWOKprYk=", + "requires": { + "event-lite": "^0.1.1", + "ieee754": "^1.1.8", + "int64-buffer": "^0.1.9", + "isarray": "^1.0.0" + } + }, "mute-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", @@ -4724,6 +4826,11 @@ "is-stream": "^1.0.1" } }, + "nofilter": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-1.0.2.tgz", + "integrity": "sha512-d38SORxm9UNoDsnPXajV9nBEebKX4/paXAlyRGnSjZuFbLLZDFUO4objr+tbybqsbqGXDWllb6gQoKUDc9q3Cg==" + }, "normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -7253,6 +7360,11 @@ "punycode": "^2.1.0" } }, + "urijs": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.1.tgz", + "integrity": "sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg==" + }, "urix": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", @@ -7298,6 +7410,11 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", + "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==" + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7343,6 +7460,15 @@ "integrity": "sha1-oW0CXrkxvQO1LzCMrtD0D86+lTI=", "dev": true }, + "websocket-framed": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/websocket-framed/-/websocket-framed-1.2.1.tgz", + "integrity": "sha512-Gzny2xBIboB/gO8ZIP2gRZQz5x0S+kxyJwBXvGhrbwolNVG5i4THm1IdA+0ga9ZWTpLCHrkcFXB+s88TlkDJUQ==", + "requires": { + "encodr": "1.2.0", + "eventemitter3": "3.1.2" + } + }, "whatwg-fetch": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", @@ -7471,6 +7597,14 @@ "signal-exit": "^3.0.2" } }, + "ws": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.0.0.tgz", + "integrity": "sha512-cknCal4k0EAOrh1SHHPPWWh4qm93g1IuGGGwBjWkXmCG7LsDtL8w9w+YVfaF+KSVwiHQKDIMsSLBVftKf9d1pg==", + "requires": { + "async-limiter": "^1.0.0" + } + }, "xdg-basedir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz", diff --git a/package.json b/package.json index 9a95f2af6..8935f7c5f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "amazon web services", "aws", "lambda", + "websocket", "api gateway" ], "files": [ @@ -125,7 +126,8 @@ "Selcuk Cihan (https://github.com/selcukcihan)", "G Roques (https://github.com/gbroques)", "Dustin Belliston (https://github.com/dwbelliston)", - "kobanyan (https://github.com/kobanyan)" + "kobanyan (https://github.com/kobanyan)", + "Ram Hardy (https://github.com/computerpunc)" ], "engines": { "node": ">=8.12.0" @@ -135,10 +137,13 @@ "@hapi/cryptiles": "^4.2.0", "@hapi/h2o2": "^8.3.0", "@hapi/hapi": "^18.3.1", + "cuid": "^2.1.6", + "hapi-plugin-websocket": "^2.1.2", "js-string-escape": "^1.0.1", "jsonpath-plus": "^0.20.1", "jsonschema": "^1.2.4", "jsonwebtoken": "^8.5.1", + "luxon": "^1.16.0", "trim-newlines": "^3.0.0", "velocityjs": "^1.1.3" }, diff --git a/src/ApiGateway.js b/src/ApiGateway.js new file mode 100644 index 000000000..767124122 --- /dev/null +++ b/src/ApiGateway.js @@ -0,0 +1,950 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { performance, PerformanceObserver } = require('perf_hooks'); +const hapi = require('@hapi/hapi'); +const h2o2 = require('@hapi/h2o2'); +const debugLog = require('./debugLog'); +const jsonPath = require('./jsonPath'); +const createLambdaContext = require('./createLambdaContext'); +const createVelocityContext = require('./createVelocityContext'); +const createLambdaProxyContext = require('./createLambdaProxyContext'); +const renderVelocityTemplateObject = require('./renderVelocityTemplateObject'); +const createAuthScheme = require('./createAuthScheme'); +const functionHelper = require('./functionHelper'); +const Endpoint = require('./Endpoint'); +const parseResources = require('./parseResources'); +const { detectEncoding, createUniqueId } = require('./utils'); +const authFunctionNameExtractor = require('./authFunctionNameExtractor'); +const requestBodyValidator = require('./requestBodyValidator'); + +module.exports = class ApiGateway { + constructor(serverless, options, velocityContextOptions) { + this.serverless = serverless; + this.service = serverless.service; + this.serverlessLog = serverless.cli.log.bind(serverless.cli); + this.options = options; + this.exitCode = 0; + + this.requests = {}; + this.lastRequestOptions = null; + this.velocityContextOptions = velocityContextOptions; + } + + printBlankLine() { + console.log(); + } + + logPluginIssue() { + this.serverlessLog('If you think this is an issue with the plugin please submit it, thanks!'); + this.serverlessLog('https://github.com/dherault/serverless-offline/issues'); + } + + _createServer() { + const serverOptions = { + host: this.options.host, + port: this.options.port, + router: { + stripTrailingSlash: !this.options.preserveTrailingSlash, // removes trailing slashes on incoming paths. + }, + }; + + const httpsDir = this.options.httpsProtocol; + + // HTTPS support + if (typeof httpsDir === 'string' && httpsDir.length > 0) { + serverOptions.tls = { + cert: fs.readFileSync(path.resolve(httpsDir, 'cert.pem'), 'ascii'), + key: fs.readFileSync(path.resolve(httpsDir, 'key.pem'), 'ascii'), + }; + } + + serverOptions.state = this.options.enforceSecureCookies ? { + isHttpOnly: true, + isSameSite: false, + isSecure: true, + } : { + isHttpOnly: false, + isSameSite: false, + isSecure: false, + }; + + // Hapijs server creation + this.server = hapi.server(serverOptions); + + this.server.register(h2o2).catch(err => err && this.serverlessLog(err)); + + // Enable CORS preflight response + this.server.ext('onPreResponse', (request, h) => { + if (request.headers.origin) { + const response = request.response.isBoom ? request.response.output : request.response; + + response.headers['access-control-allow-origin'] = request.headers.origin; + response.headers['access-control-allow-credentials'] = 'true'; + + if (request.method === 'options') { + response.statusCode = 200; + response.headers['access-control-expose-headers'] = 'content-type, content-length, etag'; + response.headers['access-control-max-age'] = 60 * 10; + + if (request.headers['access-control-request-headers']) { + response.headers['access-control-allow-headers'] = request.headers['access-control-request-headers']; + } + + if (request.headers['access-control-request-method']) { + response.headers['access-control-allow-methods'] = request.headers['access-control-request-method']; + } + } + } + + return h.continue; + }); + + return this.server; + } + + _extractAuthFunctionName(endpoint) { + const result = authFunctionNameExtractor(endpoint, this.serverlessLog); + + return result.unsupportedAuth ? null : result.authorizerName; + } + + _configureAuthorization(endpoint, funName, method, epath, servicePath, serviceRuntime) { + if (!endpoint.authorizer) { + return null; + } + + const authFunctionName = this._extractAuthFunctionName(endpoint); + + if (!authFunctionName) { + return null; + } + + this.serverlessLog(`Configuring Authorization: ${endpoint.path} ${authFunctionName}`); + + const authFunction = this.service.getFunction(authFunctionName); + + if (!authFunction) return this.serverlessLog(`WARNING: Authorization function ${authFunctionName} does not exist`); + + const authorizerOptions = { + identitySource: 'method.request.header.Authorization', + identityValidationExpression: '(.*)', + resultTtlInSeconds: '300', + }; + + if (typeof endpoint.authorizer === 'string') { + authorizerOptions.name = authFunctionName; + } + else { + Object.assign(authorizerOptions, endpoint.authorizer); + } + + // Create a unique scheme per endpoint + // This allows the methodArn on the event property to be set appropriately + const authKey = `${funName}-${authFunctionName}-${method}-${epath}`; + const authSchemeName = `scheme-${authKey}`; + const authStrategyName = `strategy-${authKey}`; // set strategy name for the route config + + debugLog(`Creating Authorization scheme for ${authKey}`); + + // Create the Auth Scheme for the endpoint + const scheme = createAuthScheme( + authFunction, + authorizerOptions, + authFunctionName, + epath, + this.options, + this.serverlessLog, + servicePath, + serviceRuntime, + this.serverless + ); + + // Set the auth scheme and strategy on the server + this.server.auth.scheme(authSchemeName, scheme); + this.server.auth.strategy(authStrategyName, authSchemeName); + + return authStrategyName; + } + + // All done, we can listen to incomming requests + async _listen() { + try { + await this.server.start(); + } + catch (e) { + console.error(`Unexpected error while starting serverless-offline server on port ${this.options.port}:`, e); + process.exit(1); + } + + this.printBlankLine(); + this.serverlessLog(`Offline [HTTP] listening on http${this.options.httpsProtocol ? 's' : ''}://${this.options.host}:${this.options.port}`); + this.serverlessLog('Enter "rp" to replay the last request'); + + process.openStdin().addListener('data', data => { + // note: data is an object, and when converted to a string it will + // end with a linefeed. so we (rather crudely) account for that + // with toString() and then trim() + if (data.toString().trim() === 'rp') { + this._injectLastRequest(); + } + }); + } + + _createRoutes(event, funOptions, protectedRoutes, funName, servicePath, serviceRuntime, defaultContentType, key, fun) { + // Handle Simple http setup, ex. - http: GET users/index + if (typeof event.http === 'string') { + const [method, path] = event.http.split(' '); + event.http = { method, path }; + } + + // generate an enpoint via the endpoint class + const endpoint = new Endpoint(event.http, funOptions).generate(); + + const integration = endpoint.integration || 'lambda-proxy'; + const requestBodyValidationModel = (['lambda', 'lambda-proxy'].includes(integration) + ? requestBodyValidator.getModel(this.service.custom, event.http, this.serverlessLog) + : null); + const epath = endpoint.path; + const method = endpoint.method.toUpperCase(); + const requestTemplates = endpoint.requestTemplates; + + // Prefix must start and end with '/' BUT path must not end with '/' + let fullPath = this.options.prefix + (epath.startsWith('/') ? epath.slice(1) : epath); + if (fullPath !== '/' && fullPath.endsWith('/')) fullPath = fullPath.slice(0, -1); + fullPath = fullPath.replace(/\+}/g, '*}'); + + if (event.http.private) { + protectedRoutes.push(`${method}#${fullPath}`); + } + + this.serverlessLog(`${method} ${fullPath}${requestBodyValidationModel && !this.options.disableModelValidation ? ` - request body will be validated against ${requestBodyValidationModel.name}` : ''}`); + + // If the endpoint has an authorization function, create an authStrategy for the route + const authStrategyName = this.options.noAuth ? null : this._configureAuthorization(endpoint, funName, method, epath, servicePath, serviceRuntime); + + let cors = null; + if (endpoint.cors) { + cors = { + credentials: endpoint.cors.credentials || this.options.corsConfig.credentials, + exposedHeaders: this.options.corsConfig.exposedHeaders, + headers: endpoint.cors.headers || this.options.corsConfig.headers, + origin: endpoint.cors.origins || this.options.corsConfig.origin, + }; + } + + // Route creation + const routeMethod = method === 'ANY' ? '*' : method; + + const state = this.options.disableCookieValidation ? { + failAction: 'ignore', + parse: false, + } : { + failAction: 'error', + parse: true, + }; + + const routeConfig = { + auth: authStrategyName, + cors, + state, + timeout: { socket: false }, + }; + + // skip HEAD routes as hapi will fail with 'Method name not allowed: HEAD ...' + // for more details, check https://github.com/dherault/serverless-offline/issues/204 + if (routeMethod === 'HEAD') { + this.serverlessLog('HEAD method event detected. Skipping HAPI server route mapping ...'); + + return; + } + + if (routeMethod !== 'HEAD' && routeMethod !== 'GET') { + // maxBytes: Increase request size from 1MB default limit to 10MB. + // Cf AWS API GW payload limits. + routeConfig.payload = { parse: false, maxBytes: 1024 * 1024 * 10 }; + } + + this.server.route({ + config: routeConfig, + method: routeMethod, + path: fullPath, + handler: (request, h) => { // Here we go + // Store current request as the last one + this.lastRequestOptions = { + method: request.method, + url: request.url.href, + headers: request.headers, + payload: request.payload, + }; + + if (request.auth.credentials && request.auth.strategy) { + this.lastRequestOptions.auth = request.auth; + } + + // Payload processing + const encoding = detectEncoding(request); + + request.payload = request.payload && request.payload.toString(encoding); + request.rawPayload = request.payload; + + // Headers processing + // Hapi lowercases the headers whereas AWS does not + // so we recreate a custom headers object from the raw request + const headersArray = request.raw.req.rawHeaders; + + // During tests, `server.inject` uses *shot*, a package + // for performing injections that does not entirely mimick + // Hapi's usual request object. rawHeaders are then missing + // Hence the fallback for testing + + // Normal usage + if (headersArray) { + request.unprocessedHeaders = {}; + request.multiValueHeaders = {}; + + for (let i = 0; i < headersArray.length; i += 2) { + request.unprocessedHeaders[headersArray[i]] = headersArray[i + 1]; + request.multiValueHeaders[headersArray[i]] = (request.multiValueHeaders[headersArray[i]] || []).concat(headersArray[i + 1]); + } + } + // Lib testing + else { + request.unprocessedHeaders = request.headers; + } + + // Incomming request message + this.printBlankLine(); + this.serverlessLog(`${method} ${request.path} (λ: ${funName})`); + + // Check for APIKey + if ((protectedRoutes.includes(`${routeMethod}#${fullPath}`) || protectedRoutes.includes(`ANY#${fullPath}`)) && !this.options.noAuth) { + const errorResponse = () => h.response({ message: 'Forbidden' }).code(403).type('application/json').header('x-amzn-ErrorType', 'ForbiddenException'); + + if ('x-api-key' in request.headers) { + const requestToken = request.headers['x-api-key']; + if (requestToken !== this.options.apiKey) { + debugLog(`Method ${method} of function ${funName} token ${requestToken} not valid`); + + return errorResponse(); + } + } + else if (request.auth && request.auth.credentials && 'usageIdentifierKey' in request.auth.credentials) { + const usageIdentifierKey = request.auth.credentials.usageIdentifierKey; + if (usageIdentifierKey !== this.options.apiKey) { + debugLog(`Method ${method} of function ${funName} token ${usageIdentifierKey} not valid`); + + return errorResponse(); + } + } + else { + debugLog(`Missing x-api-key on private function ${funName}`); + + return errorResponse(); + } + } + // Shared mutable state is the root of all evil they say + const requestId = createUniqueId(); + this.requests[requestId] = { done: false }; + this.currentRequestId = requestId; + + const response = h.response(); + const contentType = request.mime || defaultContentType; + + // default request template to '' if we don't have a definition pushed in from serverless or endpoint + const requestTemplate = typeof requestTemplates !== 'undefined' && integration === 'lambda' ? requestTemplates[contentType] : ''; + + // https://hapijs.com/api#route-configuration doesn't seem to support selectively parsing + // so we have to do it ourselves + const contentTypesThatRequirePayloadParsing = ['application/json', 'application/vnd.api+json']; + if (contentTypesThatRequirePayloadParsing.includes(contentType) && request.payload && request.payload.length > 1) { + try { + if (!request.payload || request.payload.length < 1) { + request.payload = '{}'; + } + + request.payload = JSON.parse(request.payload); + } + catch (err) { + debugLog('error in converting request.payload to JSON:', err); + } + } + + debugLog('requestId:', requestId); + debugLog('contentType:', contentType); + debugLog('requestTemplate:', requestTemplate); + debugLog('payload:', request.payload); + + /* HANDLER LAZY LOADING */ + + let userHandler; // The lambda function + Object.assign(process.env, this.originalEnvironment); + + try { + if (this.options.noEnvironment) { + // This evict errors in server when we use aws services like ssm + const baseEnvironment = { + AWS_REGION: 'dev', + }; + if (!process.env.AWS_PROFILE) { + baseEnvironment.AWS_ACCESS_KEY_ID = 'dev'; + baseEnvironment.AWS_SECRET_ACCESS_KEY = 'dev'; + } + + process.env = Object.assign(baseEnvironment, process.env); + } + else { + Object.assign( + process.env, + { AWS_REGION: this.service.provider.region }, + this.service.provider.environment, + this.service.functions[key].environment + ); + } + process.env._HANDLER = fun.handler; + userHandler = functionHelper.createHandler(funOptions, this.options); + } + catch (err) { + return this._reply500(response, `Error while loading ${funName}`, err); + } + + /* REQUEST TEMPLATE PROCESSING (event population) */ + + let event = {}; + + if (integration === 'lambda') { + if (requestTemplate) { + try { + debugLog('_____ REQUEST TEMPLATE PROCESSING _____'); + // Velocity templating language parsing + const velocityContext = createVelocityContext(request, this.velocityContextOptions, request.payload || {}); + event = renderVelocityTemplateObject(requestTemplate, velocityContext); + } + catch (err) { + return this._reply500(response, `Error while parsing template "${contentType}" for ${funName}`, err); + } + } + else if (typeof request.payload === 'object') { + event = request.payload || {}; + } + } + else if (integration === 'lambda-proxy') { + event = createLambdaProxyContext(request, this.options, this.velocityContextOptions.stageVariables); + } + + event.isOffline = true; + + if (this.service.custom && this.service.custom.stageVariables) { + event.stageVariables = this.service.custom.stageVariables; + } + else if (integration !== 'lambda-proxy') { + event.stageVariables = {}; + } + + debugLog('event:', event); + + return new Promise(resolve => { + // We create the context, its callback (context.done/succeed/fail) will send the HTTP response + const lambdaContext = createLambdaContext(fun, this.service.provider, (err, data, fromPromise) => { + // Everything in this block happens once the lambda function has resolved + debugLog('_____ HANDLER RESOLVED _____'); + + // User should not call context.done twice + if (!this.requests[requestId] || this.requests[requestId].done) { + this.printBlankLine(); + const warning = fromPromise + ? `Warning: handler '${funName}' returned a promise and also uses a callback!\nThis is problematic and might cause issues in your lambda.` + : `Warning: context.done called twice within handler '${funName}'!`; + this.serverlessLog(warning); + debugLog('requestId:', requestId); + + return; + } + + this.requests[requestId].done = true; + + let result = data; + let responseName = 'default'; + const { contentHandling, responseContentType } = endpoint; + + /* RESPONSE SELECTION (among endpoint's possible responses) */ + + // Failure handling + let errorStatusCode = 0; + if (err) { + // Since the --useSeparateProcesses option loads the handler in + // a separate process and serverless-offline communicates with it + // over IPC, we are unable to catch JavaScript unhandledException errors + // when the handler code contains bad JavaScript. Instead, we "catch" + // it here and reply in the same way that we would have above when + // we lazy-load the non-IPC handler function. + if (this.options.useSeparateProcesses && err.ipcException) { + return resolve(this._reply500(response, `Error while loading ${funName}`, err)); + } + + const errorMessage = (err.message || err).toString(); + + const re = /\[(\d{3})]/; + const found = errorMessage.match(re); + if (found && found.length > 1) { + errorStatusCode = found[1]; + } + else { + errorStatusCode = '500'; + } + + // Mocks Lambda errors + result = { + errorMessage, + errorType: err.constructor.name, + stackTrace: this._getArrayStackTrace(err.stack), + }; + + this.serverlessLog(`Failure: ${errorMessage}`); + + if (!this.options.hideStackTraces) { + console.error(err.stack); + } + + for (const key in endpoint.responses) { + if (key !== 'default' && errorMessage.match(`^${endpoint.responses[key].selectionPattern || key}$`)) { + responseName = key; + break; + } + } + } + + debugLog(`Using response '${responseName}'`); + const chosenResponse = endpoint.responses[responseName]; + + /* RESPONSE PARAMETERS PROCCESSING */ + + const responseParameters = chosenResponse.responseParameters; + + if (responseParameters) { + + const responseParametersKeys = Object.keys(responseParameters); + + debugLog('_____ RESPONSE PARAMETERS PROCCESSING _____'); + debugLog(`Found ${responseParametersKeys.length} responseParameters for '${responseName}' response`); + + // responseParameters use the following shape: "key": "value" + Object.entries(responseParametersKeys).forEach(([key, value]) => { + + const keyArray = key.split('.'); // eg: "method.response.header.location" + const valueArray = value.split('.'); // eg: "integration.response.body.redirect.url" + + debugLog(`Processing responseParameter "${key}": "${value}"`); + + // For now the plugin only supports modifying headers + if (key.startsWith('method.response.header') && keyArray[3]) { + + const headerName = keyArray.slice(3).join('.'); + let headerValue; + debugLog('Found header in left-hand:', headerName); + + if (value.startsWith('integration.response')) { + if (valueArray[2] === 'body') { + + debugLog('Found body in right-hand'); + headerValue = (valueArray[3] ? jsonPath(result, valueArray.slice(3).join('.')) : result).toString(); + + } + else { + this.printBlankLine(); + this.serverlessLog(`Warning: while processing responseParameter "${key}": "${value}"`); + this.serverlessLog(`Offline plugin only supports "integration.response.body[.JSON_path]" right-hand responseParameter. Found "${value}" instead. Skipping.`); + this.logPluginIssue(); + this.printBlankLine(); + } + } + else { + headerValue = value.match(/^'.*'$/) ? value.slice(1, -1) : value; // See #34 + } + // Applies the header; + debugLog(`Will assign "${headerValue}" to header "${headerName}"`); + response.header(headerName, headerValue); + + } + else { + this.printBlankLine(); + this.serverlessLog(`Warning: while processing responseParameter "${key}": "${value}"`); + this.serverlessLog(`Offline plugin only supports "method.response.header.PARAM_NAME" left-hand responseParameter. Found "${key}" instead. Skipping.`); + this.logPluginIssue(); + this.printBlankLine(); + } + }); + } + + let statusCode = 200; + + if (integration === 'lambda') { + + const endpointResponseHeaders = (endpoint.response && endpoint.response.headers) || {}; + + Object.entries(endpointResponseHeaders) + .filter(([, value]) => typeof value === 'string' && /^'.*?'$/.test(value)) + .forEach(([key, value]) => response.header(key, value.slice(1, -1))); + + /* LAMBDA INTEGRATION RESPONSE TEMPLATE PROCCESSING */ + + // If there is a responseTemplate, we apply it to the result + const { responseTemplates } = chosenResponse; + + if (typeof responseTemplates === 'object') { + const responseTemplatesKeys = Object.keys(responseTemplates); + + if (responseTemplatesKeys.length) { + + // BAD IMPLEMENTATION: first key in responseTemplates + const responseTemplate = responseTemplates[responseContentType]; + + if (responseTemplate && responseTemplate !== '\n') { + + debugLog('_____ RESPONSE TEMPLATE PROCCESSING _____'); + debugLog(`Using responseTemplate '${responseContentType}'`); + + try { + const reponseContext = createVelocityContext(request, this.velocityContextOptions, result); + result = renderVelocityTemplateObject({ root: responseTemplate }, reponseContext).root; + } + catch (error) { + this.serverlessLog(`Error while parsing responseTemplate '${responseContentType}' for lambda ${funName}:`); + console.log(error.stack); + } + } + } + } + + /* LAMBDA INTEGRATION HAPIJS RESPONSE CONFIGURATION */ + + statusCode = errorStatusCode !== 0 ? errorStatusCode : (chosenResponse.statusCode || 200); + + if (!chosenResponse.statusCode) { + this.printBlankLine(); + this.serverlessLog(`Warning: No statusCode found for response "${responseName}".`); + } + + response.header('Content-Type', responseContentType, { + override: false, // Maybe a responseParameter set it already. See #34 + }); + + response.statusCode = statusCode; + + if (contentHandling === 'CONVERT_TO_BINARY') { + response.encoding = 'binary'; + response.source = Buffer.from(result, 'base64'); + response.variety = 'buffer'; + } + else { + if (result && result.body && typeof result.body !== 'string') { + return this._reply500(response, 'According to the API Gateway specs, the body content must be stringified. Check your Lambda response and make sure you are invoking JSON.stringify(YOUR_CONTENT) on your body object', {}); + } + response.source = result; + } + } + else if (integration === 'lambda-proxy') { + + /* LAMBDA PROXY INTEGRATION HAPIJS RESPONSE CONFIGURATION */ + + response.statusCode = statusCode = (result || {}).statusCode || 200; + + const headers = {}; + if (result && result.headers) { + Object.keys(result.headers).forEach(header => { + headers[header] = (headers[header] || []).concat(result.headers[header]); + }); + } + if (result && result.multiValueHeaders) { + Object.keys(result.multiValueHeaders).forEach(header => { + headers[header] = (headers[header] || []).concat(result.multiValueHeaders[header]); + }); + } + + debugLog('headers', headers); + + Object.keys(headers).forEach(header => { + if (header.toLowerCase() === 'set-cookie') { + headers[header].forEach(headerValue => { + const cookieName = headerValue.slice(0, headerValue.indexOf('=')); + const cookieValue = headerValue.slice(headerValue.indexOf('=') + 1); + h.state(cookieName, cookieValue, { encoding: 'none', strictHeader: false }); + }); + } + else { + headers[header].forEach(headerValue => { + // it looks like Hapi doesn't support multiple headers with the same name, + // appending values is the closest we can come to the AWS behavior. + response.header(header, headerValue, { append: true }); + }); + } + }); + + response.header('Content-Type', 'application/json', { override: false, duplicate: false }); + + if (result && typeof result.body !== 'undefined') { + if (result.isBase64Encoded) { + response.encoding = 'binary'; + response.source = Buffer.from(result.body, 'base64'); + response.variety = 'buffer'; + } + else { + if (result && result.body && typeof result.body !== 'string') { + return this._reply500(response, 'According to the API Gateway specs, the body content must be stringified. Check your Lambda response and make sure you are invoking JSON.stringify(YOUR_CONTENT) on your body object', {}); + } + response.source = result.body; + } + } + } + + // Log response + let whatToLog = result; + + try { + whatToLog = JSON.stringify(result); + } + catch (error) { + // nothing + } + finally { + if (this.options.printOutput) this.serverlessLog(err ? `Replying ${statusCode}` : `[${statusCode}] ${whatToLog}`); + debugLog('requestId:', requestId); + } + + // Bon voyage! + resolve(response); + }); + + // Now we are outside of createLambdaContext, so this happens before the handler gets called: + + // We cannot use Hapijs's timeout feature because the logic above can take a significant time, so we implement it ourselves + this.requests[requestId].timeout = this.options.noTimeout ? null : setTimeout( + this._replyTimeout.bind(this, response, resolve, funName, funOptions.funTimeout, requestId), + funOptions.funTimeout + ); + + // If request body validation is enabled, validate body against the request model. + if (requestBodyValidationModel && !this.options.disableModelValidation) { + try { + requestBodyValidator.validate(requestBodyValidationModel, event.body); + } + catch (error) { + // When request body validation fails, APIG will return back 400 as detailed in: + // https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-request-validation.html + return resolve(this._replyError(400, response, `Invalid request body for '${funName}' handler`, error)); + } + } + + // Finally we call the handler + debugLog('_____ CALLING HANDLER _____'); + + const cleanup = () => { + this._clearTimeout(requestId); + delete this.requests[requestId]; + }; + + let x; + + if (this.options.showDuration) { + performance.mark(`${requestId}-start`); + + const obs = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + this.serverlessLog(`Duration ${entry.duration.toFixed(2)} ms (λ: ${entry.name})`); + } + + obs.disconnect(); + }); + + obs.observe({ entryTypes: ['measure'] }); + } + + try { + x = userHandler(event, lambdaContext, (err, result) => { + setTimeout(cleanup, 0); + + if (this.options.showDuration) { + performance.mark(`${requestId}-end`); + performance.measure(funName, `${requestId}-start`, `${requestId}-end`); + } + + return lambdaContext.done(err, result); + }); + + // Promise support + if (!this.requests[requestId].done) { + if (x && typeof x.then === 'function') { + x.then(lambdaContext.succeed).catch(lambdaContext.fail).then(cleanup, cleanup); + } + else if (x instanceof Error) { + lambdaContext.fail(x); + } + } + } + catch (error) { + cleanup(); + + return resolve(this._reply500(response, `Uncaught error in your '${funName}' handler`, error)); + } + }); + }, + }); + } + + // Bad news + _replyError(responseCode, response, message, error) { + this.serverlessLog(message); + + console.error(error); + + response.header('Content-Type', 'application/json'); + + /* eslint-disable no-param-reassign */ + response.statusCode = responseCode; + response.source = { + errorMessage: message, + errorType: error.constructor.name, + stackTrace: this._getArrayStackTrace(error.stack), + offlineInfo: 'If you believe this is an issue with serverless-offline please submit it, thanks. https://github.com/dherault/serverless-offline/issues', + }; + /* eslint-enable no-param-reassign */ + + return response; + } + + _reply500(response, message, err) { + // APIG replies 200 by default on failures + return this._replyError(200, response, message, err); + } + + _replyTimeout(response, resolve, funName, funTimeout, requestId) { + if (this.currentRequestId !== requestId) return; + + this.serverlessLog(`Replying timeout after ${funTimeout}ms`); + /* eslint-disable no-param-reassign */ + response.statusCode = 503; + response.source = `[Serverless-Offline] Your λ handler '${funName}' timed out after ${funTimeout}ms.`; + /* eslint-enable no-param-reassign */ + resolve(response); + } + + _clearTimeout(requestId) { + const { timeout } = this.requests[requestId] || {}; + clearTimeout(timeout); + } + + _createResourceRoutes() { + if (!this.options.resourceRoutes) return true; + const resourceRoutesOptions = this.options.resourceRoutes; + const resourceRoutes = parseResources(this.service.resources); + + if (!resourceRoutes || !Object.keys(resourceRoutes).length) return true; + + this.printBlankLine(); + this.serverlessLog('Routes defined in resources:'); + + Object.entries(resourceRoutes).forEach(([methodId, resourceRoutesObj]) => { + const { isProxy, method, path, pathResource, proxyUri } = resourceRoutesObj; + + if (!isProxy) { + return this.serverlessLog(`WARNING: Only HTTP_PROXY is supported. Path '${pathResource}' is ignored.`); + } + if (!path) { + return this.serverlessLog(`WARNING: Could not resolve path for '${methodId}'.`); + } + + let fullPath = this.options.prefix + (pathResource.startsWith('/') ? pathResource.slice(1) : pathResource); + if (fullPath !== '/' && fullPath.endsWith('/')) fullPath = fullPath.slice(0, -1); + fullPath = fullPath.replace(/\+}/g, '*}'); + + const proxyUriOverwrite = resourceRoutesOptions[methodId] || {}; + const proxyUriInUse = proxyUriOverwrite.Uri || proxyUri; + + if (!proxyUriInUse) { + return this.serverlessLog(`WARNING: Could not load Proxy Uri for '${methodId}'`); + } + + const routeMethod = method === 'ANY' ? '*' : method; + const routeConfig = { cors: this.options.corsConfig }; + + // skip HEAD routes as hapi will fail with 'Method name not allowed: HEAD ...' + // for more details, check https://github.com/dherault/serverless-offline/issues/204 + if (routeMethod === 'HEAD') { + this.serverlessLog('HEAD method event detected. Skipping HAPI server route mapping ...'); + + return; + } + + if (routeMethod !== 'HEAD' && routeMethod !== 'GET') { + routeConfig.payload = { parse: false }; + } + + this.serverlessLog(`${method} ${fullPath} -> ${proxyUriInUse}`); + this.server.route({ + config: routeConfig, + method: routeMethod, + path: fullPath, + handler: (request, h) => { + const { params } = request; + let resultUri = proxyUriInUse; + + Object.entries(params).forEach(([key, value]) => { + resultUri = resultUri.replace(`{${key}}`, value); + }); + + if (request.url.search !== null) { + resultUri += request.url.search; // search is empty string by default + } + + this.serverlessLog(`PROXY ${request.method} ${request.url.path} -> ${resultUri}`); + + return h.proxy({ uri: resultUri, passThrough: true }); + }, + }); + }); + } + + _create404Route() { + // If a {proxy+} route exists, don't conflict with it + if (this.server.match('*', '/{p*}')) return; + + this.server.route({ + config: { cors: this.options.corsConfig }, + method: '*', + path: '/{p*}', + handler: (request, h) => { + const response = h.response({ + statusCode: 404, + error: 'Serverless-offline: route not found.', + currentRoute: `${request.method} - ${request.path}`, + existingRoutes: this.server.table() + .filter(route => route.path !== '/{p*}') // Exclude this (404) route + .sort((a, b) => a.path <= b.path ? -1 : 1) // Sort by path + .map(route => `${route.method} - ${route.path}`), // Human-friendly result + }); + response.statusCode = 404; + + return response; + }, + }); + } + + _getArrayStackTrace(stack) { + if (!stack) return null; + + const splittedStack = stack.split('\n'); + + return splittedStack.slice(0, splittedStack.findIndex(item => item.match(/server.route.handler.createLambdaContext/))).map(line => line.trim()); + } + + _injectLastRequest() { + if (this.lastRequestOptions) { + this.serverlessLog('Replaying HTTP last request'); + this.server.inject(this.lastRequestOptions); + } + else { + this.serverlessLog('No last HTTP request to replay!'); + } + } +}; diff --git a/src/ApiGatewayWebSocket.js b/src/ApiGatewayWebSocket.js new file mode 100644 index 000000000..ad5b18f37 --- /dev/null +++ b/src/ApiGatewayWebSocket.js @@ -0,0 +1,365 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const hapi = require('@hapi/hapi'); +const h2o2 = require('@hapi/h2o2'); +const hapiPluginWebsocket = require('hapi-plugin-websocket'); +const debugLog = require('./debugLog'); +const createLambdaContext = require('./createLambdaContext'); +const functionHelper = require('./functionHelper'); +const { createUniqueId } = require('./utils'); +const authFunctionNameExtractor = require('./authFunctionNameExtractor'); +const wsHelpers = require('./websocketHelpers'); + +module.exports = class ApiGatewayWebSocket { + constructor(serverless, options) { + this.serverless = serverless; + this.service = serverless.service; + this.serverlessLog = serverless.cli.log.bind(serverless.cli); + this.options = options; + this.exitCode = 0; + this.clients = new Map(); + this.actions = {}; + this.websocketsApiRouteSelectionExpression = serverless.service.provider.websocketsApiRouteSelectionExpression || '$request.body.action'; + } + + printBlankLine() { + console.log(); + } + + _createWebSocket() { + // start COPY PASTE FROM HTTP SERVER CODE + const serverOptions = { + host: this.options.host, + port: this.options.websocketPort, + router: { + stripTrailingSlash: !this.options.preserveTrailingSlash, // removes trailing slashes on incoming paths. + }, + }; + + const httpsDir = this.options.httpsProtocol; + + // HTTPS support + if (typeof httpsDir === 'string' && httpsDir.length > 0) { + serverOptions.tls = { + key: fs.readFileSync(path.resolve(httpsDir, 'key.pem'), 'ascii'), + cert: fs.readFileSync(path.resolve(httpsDir, 'cert.pem'), 'ascii'), + }; + } + + serverOptions.state = this.options.enforceSecureCookies ? { + isHttpOnly: true, + isSameSite: false, + isSecure: true, + } : { + isHttpOnly: false, + isSameSite: false, + isSecure: false, + }; + + // Hapijs server creation + this.wsServer = hapi.server(serverOptions); + + this.wsServer.register(h2o2).catch(err => err && this.serverlessLog(err)); + + // Enable CORS preflight response + this.wsServer.ext('onPreResponse', (request, h) => { + if (request.headers.origin) { + const response = request.response.isBoom ? request.response.output : request.response; + + response.headers['access-control-allow-origin'] = request.headers.origin; + response.headers['access-control-allow-credentials'] = 'true'; + + if (request.method === 'options') { + response.statusCode = 200; + response.headers['access-control-expose-headers'] = 'content-type, content-length, etag'; + response.headers['access-control-max-age'] = 60 * 10; + + if (request.headers['access-control-request-headers']) { + response.headers['access-control-allow-headers'] = request.headers['access-control-request-headers']; + } + + if (request.headers['access-control-request-method']) { + response.headers['access-control-allow-methods'] = request.headers['access-control-request-method']; + } + } + } + + return h.continue; + }); + // end COPY PASTE FROM HTTP SERVER CODE + + this.wsServer.register(hapiPluginWebsocket).catch(err => err && this.serverlessLog(err)); + + const doAction = (ws, connectionId, name, event, doDefaultAction) => { + const sendError = err => { + if (ws.readyState === /* OPEN */1) { + ws.send(JSON.stringify({ message:'Internal server error', connectionId, requestId:'1234567890' })); + } + + // mimic AWS behaviour (close connection) when the $connect action handler throws + if (name === '$connect') { + ws.close(); + } + + debugLog(`Error in handler of action ${action}`, err); + }; + + let action = this.actions[name]; + + if (!action && doDefaultAction) action = this.actions.$default; + if (!action) return; + + function cb(err) { + if (!err) return; + sendError(err); + } + + // TEMP + const func = { + ...action.fun, + name, + }; + const context = createLambdaContext(func, this.service.provider, cb); + + let p = null; + + try { + p = action.handler(event, context, cb); + } + catch (err) { + sendError(err); + } + + if (p) { + p.catch(err => { + sendError(err); + }); + } + }; + + this.wsServer.route({ + method: 'POST', + path: '/', + config: { + payload: { + output: 'data', + parse: true, + allow: 'application/json', + }, + plugins: { + websocket: { + only: true, + initially: false, + connect: ({ ws, req }) => { + const queryStringParameters = parseQuery(req.url); + const connection = { connectionId:createUniqueId(), connectionTime:Date.now() }; + + debugLog(`connect:${connection.connectionId}`); + + this.clients.set(ws, connection); + + let event = wsHelpers.createConnectEvent('$connect', 'CONNECT', connection, this.options); + + if (Object.keys(queryStringParameters).length > 0) { + event = { queryStringParameters, ...event }; + } + + doAction(ws, connection.connectionId, '$connect', event, false); + }, + disconnect: ({ ws }) => { + const connection = this.clients.get(ws); + + debugLog(`disconnect:${connection.connectionId}`); + + this.clients.delete(ws); + + const event = wsHelpers.createDisconnectEvent('$disconnect', 'DISCONNECT', connection); + + doAction(ws, connection.connectionId, '$disconnect', event, false); + }, + }, + }, + }, + handler: (request, h) => { + const { initially, ws } = request.websocket(); + + if (!request.payload || initially) { + return h.response().code(204); + } + + const connection = this.clients.get(ws); + let actionName = null; + + if (this.websocketsApiRouteSelectionExpression.startsWith('$request.body.')) { + actionName = request.payload; + + if (typeof actionName === 'object') { + this.websocketsApiRouteSelectionExpression + .replace('$request.body.', '') + .split('.') + .forEach(key => { + if (actionName) { + actionName = actionName[key]; + } + }); + } + else actionName = null; + } + + if (typeof actionName !== 'string') { + actionName = null; + } + + const action = actionName || '$default'; + + debugLog(`action:${action} on connection=${connection.connectionId}`); + + const event = wsHelpers.createEvent(action, 'MESSAGE', connection, request.payload); + + doAction(ws, connection.connectionId, action, event, true); + + return h.response().code(204); + }, + }); + + this.wsServer.route({ + method: 'GET', + path: '/{path*}', + handler: (request, h) => h.response().code(426), + }); + + this.wsServer.route({ + method: 'POST', + path: '/@connections/{connectionId}', + config: { payload: { parse: false } }, + handler: (request, h) => { + debugLog(`got POST to ${request.url}`); + + const getByConnectionId = (map, searchValue) => { + for (const [key, connection] of map.entries()) { + if (connection.connectionId === searchValue) return key; + } + + return undefined; + }; + + const ws = getByConnectionId(this.clients, request.params.connectionId); + + if (!ws) return h.response().code(410); + if (!request.payload) return ''; + + ws.send(request.payload.toString()); + + debugLog(`sent data to connection:${request.params.connectionId}`); + + return ''; + }, + }); + + this.wsServer.route({ + method: 'DELETE', + path: '/@connections/{connectionId}', + config: { payload: { parse: false } }, + handler: (request, h) => { + debugLog(`got DELETE to ${request.url}`); + + const getByConnectionId = (map, searchValue) => { + for (const [key, connection] of map.entries()) { + if (connection.connectionId === searchValue) return key; + } + + return undefined; + }; + + const ws = getByConnectionId(this.clients, request.params.connectionId); + + if (!ws) return h.response().code(410); + + ws.close(); + + debugLog(`closed connection:${request.params.connectionId}`); + + return ''; + }, + }); + } + + _createWsAction(fun, funName, servicePath, funOptions, event) { + let handler; // The lambda function + Object.assign(process.env, this.originalEnvironment); + + try { + if (this.options.noEnvironment) { + // This evict errors in server when we use aws services like ssm + const baseEnvironment = { + AWS_REGION: 'dev', + }; + + if (!process.env.AWS_PROFILE) { + baseEnvironment.AWS_ACCESS_KEY_ID = 'dev'; + baseEnvironment.AWS_SECRET_ACCESS_KEY = 'dev'; + } + + process.env = Object.assign(baseEnvironment, process.env); + } + else { + Object.assign( + process.env, + { AWS_REGION: this.service.provider.region }, + this.service.provider.environment, + this.service.functions[funName].environment + ); + } + + process.env._HANDLER = fun.handler; + handler = functionHelper.createHandler(funOptions, this.options); + } + catch (error) { + return this.serverlessLog(`Error while loading ${funName}`, error); + } + + const actionName = event.websocket.route; + const action = { funName, fun, funOptions, servicePath, handler }; + + this.actions[actionName] = action; + this.serverlessLog(`Action '${event.websocket.route}'`); + } + + _extractAuthFunctionName(endpoint) { + const result = authFunctionNameExtractor(endpoint, this.serverlessLog); + + return result.unsupportedAuth ? null : result.authorizerName; + } + + // All done, we can listen to incomming requests + async _listen() { + try { + await this.wsServer.start(); + } + catch (error) { + console.error(`Unexpected error while starting serverless-offline websocket server on port ${this.options.websocketPort}:`, error); + process.exit(1); + } + + this.printBlankLine(); + this.serverlessLog(`Offline [websocket] listening on ws${this.options.httpsProtocol ? 's' : ''}://${this.options.host}:${this.options.websocketPort}`); + } +}; + +function parseQuery(queryString) { + const query = {}; + const parts = queryString.split('?'); + + if (parts.length < 2) return {}; + + const pairs = parts[1].split('&'); + + pairs.forEach(pair => { + const kv = pair.split('='); + query[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || ''); + }); + + return query; +} diff --git a/src/createLambdaContext.js b/src/createLambdaContext.js index 985118d12..7976da2ff 100644 --- a/src/createLambdaContext.js +++ b/src/createLambdaContext.js @@ -1,6 +1,6 @@ 'use strict'; -const { randomId } = require('./utils'); +const { createUniqueId } = require('./utils'); // https://docs.aws.amazon.com/lambda/latest/dg/limits.html // default function timeout in seconds @@ -26,7 +26,7 @@ module.exports = function createLambdaContext(fun, provider, cb) { getRemainingTimeInMillis: () => endTime - new Date().getTime(), // properties - awsRequestId: `offline_awsRequestId_${randomId()}`, + awsRequestId: `offline_awsRequestId_${createUniqueId()}`, clientContext: {}, functionName, functionVersion: `offline_functionVersion_for_${functionName}`, diff --git a/src/createLambdaProxyContext.js b/src/createLambdaProxyContext.js index d52c89a50..ba0dbb5b6 100644 --- a/src/createLambdaProxyContext.js +++ b/src/createLambdaProxyContext.js @@ -5,7 +5,7 @@ const { normalizeMultiValueQuery, normalizeQuery, nullIfEmpty, - randomId, + createUniqueId, } = require('./utils'); /* @@ -79,7 +79,7 @@ module.exports = function createLambdaProxyContext(request, options, stageVariab resourceId: 'offlineContext_resourceId', apiId: 'offlineContext_apiId', stage: options.stage, - requestId: `offlineContext_requestId_${randomId()}`, + requestId: `offlineContext_requestId_${createUniqueId()}`, identity: { cognitoIdentityPoolId: process.env.SLS_COGNITO_IDENTITY_POOL_ID || 'offlineContext_cognitoIdentityPoolId', accountId: process.env.SLS_ACCOUNT_ID || 'offlineContext_accountId', diff --git a/src/createVelocityContext.js b/src/createVelocityContext.js index cd39caf5e..7a747b7cd 100644 --- a/src/createVelocityContext.js +++ b/src/createVelocityContext.js @@ -2,7 +2,7 @@ const jsEscapeString = require('js-string-escape'); const { decode } = require('jsonwebtoken'); -const { isPlainObject, randomId } = require('./utils'); +const { isPlainObject, createUniqueId } = require('./utils'); const jsonPath = require('./jsonPath'); function escapeJavaScript(x) { @@ -66,7 +66,7 @@ module.exports = function createVelocityContext(request, options, payload) { userAgent: request.headers['user-agent'] || '', userArn: 'offlineContext_userArn', }, - requestId: `offlineContext_requestId_${randomId()}`, + requestId: `offlineContext_requestId_${createUniqueId()}`, resourceId: 'offlineContext_resourceId', resourcePath: request.route.path, stage: options.stage, diff --git a/src/functionHelper.js b/src/functionHelper.js index 8ac697580..9d7bcc6b8 100644 --- a/src/functionHelper.js +++ b/src/functionHelper.js @@ -4,7 +4,7 @@ const { fork, spawn } = require('child_process'); const path = require('path'); const trimNewlines = require('trim-newlines'); const debugLog = require('./debugLog'); -const { randomId } = require('./utils'); +const { createUniqueId } = require('./utils'); const handlerCache = {}; const messageCallbacks = {}; @@ -150,7 +150,7 @@ module.exports = { } return (event, context, done) => { - const id = randomId(); + const id = createUniqueId(); messageCallbacks[id] = done; handlerContext.inflight.add(id); handlerContext.process.send(Object.assign({}, funOptions, { id, event, context })); diff --git a/src/index.js b/src/index.js index a77b47388..74aab29a4 100755 --- a/src/index.js +++ b/src/index.js @@ -1,36 +1,14 @@ 'use strict'; -// Node dependencies -const fs = require('fs'); const path = require('path'); -const { performance, PerformanceObserver } = require('perf_hooks'); const { exec } = require('child_process'); - -// External dependencies -const hapi = require('@hapi/hapi'); -const h2o2 = require('@hapi/h2o2'); - -// Internal lib +const ApiGateway = require('./ApiGateway'); +const ApiGatewayWebSocket = require('./ApiGatewayWebSocket'); const debugLog = require('./debugLog'); -const jsonPath = require('./jsonPath'); -const createLambdaContext = require('./createLambdaContext'); -const createVelocityContext = require('./createVelocityContext'); -const createLambdaProxyContext = require('./createLambdaProxyContext'); -const renderVelocityTemplateObject = require('./renderVelocityTemplateObject'); -const createAuthScheme = require('./createAuthScheme'); const functionHelper = require('./functionHelper'); -const Endpoint = require('./Endpoint'); -const parseResources = require('./parseResources'); -const { createDefaultApiKey, detectEncoding, randomId } = require('./utils'); -const authFunctionNameExtractor = require('./authFunctionNameExtractor'); -const requestBodyValidator = require('./requestBodyValidator'); - -/* - I'm against monolithic code like this file - but splitting it induces unneeded complexity. -*/ -class Offline { +const { createDefaultApiKey } = require('./utils'); +module.exports = class Offline { constructor(serverless, options) { this.serverless = serverless; this.service = serverless.service; @@ -146,6 +124,9 @@ class Offline { useSeparateProcesses: { usage: 'Uses separate node processes for handlers', }, + websocketPort: { + usage: 'Websocket port to listen on. Default: the HTTP port + 1', + }, }, }, }; @@ -174,7 +155,8 @@ class Offline { process.env.IS_OFFLINE = true; return Promise.resolve(this._buildServer()) - .then(() => this._listen()) + .then(() => this.apiGateway._listen()) + .then(() => this.hasWebsocketRoutes && this.apiGatewayWebSocket._listen()) .then(() => this.options.exec ? this._executeShellScript() : this._listenForTermination()); } @@ -237,19 +219,23 @@ class Offline { } _buildServer() { - // Maps a request id to the request's state (done: bool, timeout: timer) - this.requests = {}; - this.lastRequestOptions = null; - // Methods this._setOptions(); // Will create meaningful options from cli options this._storeOriginalEnvironment(); // stores the original process.env for assigning upon invoking the handlers - this._createServer(); // Hapijs boot - this._createRoutes(); // API Gateway emulation - this._createResourceRoutes(); // HTTP Proxy defined in Resource - this._create404Route(); // Not found handling - return this.server; + this.apiGateway = new ApiGateway(this.serverless, this.options, this.velocityContextOptions); + + const server = this.apiGateway._createServer(); + + this.hasWebsocketRoutes = false; + this.apiGatewayWebSocket = new ApiGatewayWebSocket(this.serverless, this.options); + this.apiGatewayWebSocket._createWebSocket(); + + this._setupEvents(); + this.apiGateway._createResourceRoutes(); // HTTP Proxy defined in Resource + this.apiGateway._create404Route(); // Not found handling + + return server; } _storeOriginalEnvironment() { @@ -277,6 +263,7 @@ class Offline { noEnvironment: false, noTimeout: false, port: 3000, + websocketPort: 3001, prefix: '/', preserveTrailingSlash: false, printOutput: false, @@ -314,10 +301,10 @@ class Offline { if (this.options.corsDisallowCredentials) this.options.corsAllowCredentials = false; this.options.corsConfig = { - origin: this.options.corsAllowOrigin, - headers: this.options.corsAllowHeaders, credentials: this.options.corsAllowCredentials, exposedHeaders: this.options.corsExposedHeaders, + headers: this.options.corsAllowHeaders, + origin: this.options.corsAllowOrigin, }; this.options.cacheInvalidationRegex = new RegExp(this.options.cacheInvalidationRegex); @@ -326,68 +313,14 @@ class Offline { debugLog('options:', this.options); } - _createServer() { - const serverOptions = { - host: this.options.host, - port: this.options.port, - router: { - stripTrailingSlash: !this.options.preserveTrailingSlash, // removes trailing slashes on incoming paths. - }, - }; - - const httpsDir = this.options.httpsProtocol; - - // HTTPS support - if (typeof httpsDir === 'string' && httpsDir.length > 0) { - serverOptions.tls = { - key: fs.readFileSync(path.resolve(httpsDir, 'key.pem'), 'ascii'), - cert: fs.readFileSync(path.resolve(httpsDir, 'cert.pem'), 'ascii'), - }; - } - - serverOptions.state = this.options.enforceSecureCookies ? { - isHttpOnly: true, - isSecure: true, - isSameSite: false, - } : { - isHttpOnly: false, - isSecure: false, - isSameSite: false, - }; - - // Hapijs server creation - this.server = hapi.server(serverOptions); - - this.server.register(h2o2).catch(err => err && this.serverlessLog(err)); - - // Enable CORS preflight response - this.server.ext('onPreResponse', (request, h) => { - if (request.headers.origin) { - const response = request.response.isBoom ? request.response.output : request.response; - - response.headers['access-control-allow-origin'] = request.headers.origin; - response.headers['access-control-allow-credentials'] = 'true'; - - if (request.method === 'options') { - response.statusCode = 200; - response.headers['access-control-expose-headers'] = 'content-type, content-length, etag'; - response.headers['access-control-max-age'] = 60 * 10; - - if (request.headers['access-control-request-headers']) { - response.headers['access-control-allow-headers'] = request.headers['access-control-request-headers']; - } - - if (request.headers['access-control-request-method']) { - response.headers['access-control-allow-methods'] = request.headers['access-control-request-method']; - } - } - } - - return h.continue; - }); + end() { + this.serverlessLog('Halting offline server'); + functionHelper.cleanup(); + this.apiGateway.server.stop({ timeout: 5000 }) + .then(() => process.exit(this.exitCode)); } - _createRoutes() { + _setupEvents() { let serviceRuntime = this.service.provider.runtime; const defaultContentType = 'application/json'; const apiKeys = this.service.provider.apiKeys; @@ -448,9 +381,9 @@ class Offline { // Add proxy for lamda invoke fun.events.push({ http: { + integration: 'lambda', method: 'POST', path: `{apiVersion}/functions/${fun.name}/invocations`, - integration: 'lambda', request: { template: { // AWS SDK for NodeJS specifies as 'binary/octet-stream' not 'application/json' @@ -464,877 +397,40 @@ class Offline { }, }, }); + // Adds a route for each http endpoint fun.events.forEach(event => { - if (!event.http) return; - - // Handle Simple http setup, ex. - http: GET users/index - if (typeof event.http === 'string') { - const [method, path] = event.http.split(' '); - event.http = { method, path }; - } - - // generate an enpoint via the endpoint class - const endpoint = new Endpoint(event.http, funOptions).generate(); - - const integration = endpoint.integration || 'lambda-proxy'; - const requestBodyValidationModel = (['lambda', 'lambda-proxy'].includes(integration) - ? requestBodyValidator.getModel(this.service.custom, event.http, this.serverlessLog) - : null); - const epath = endpoint.path; - const method = endpoint.method.toUpperCase(); - const requestTemplates = endpoint.requestTemplates; - - // Prefix must start and end with '/' BUT path must not end with '/' - let fullPath = this.options.prefix + (epath.startsWith('/') ? epath.slice(1) : epath); - if (fullPath !== '/' && fullPath.endsWith('/')) fullPath = fullPath.slice(0, -1); - fullPath = fullPath.replace(/\+}/g, '*}'); - - if (event.http.private) { - protectedRoutes.push(`${method}#${fullPath}`); - } + if (event.websocket) { + this.hasWebsocketRoutes = true; - this.serverlessLog(`${method} ${fullPath}${requestBodyValidationModel && !this.options.disableModelValidation ? ` - request body will be validated against ${requestBodyValidationModel.name}` : ''}`); + experimentalWebSocketSupportWarning(); - // If the endpoint has an authorization function, create an authStrategy for the route - const authStrategyName = this.options.noAuth ? null : this._configureAuthorization(endpoint, funName, method, epath, servicePath, serviceRuntime); - - let cors = null; - if (endpoint.cors) { - cors = { - origin: endpoint.cors.origins || this.options.corsConfig.origin, - headers: endpoint.cors.headers || this.options.corsConfig.headers, - credentials: endpoint.cors.credentials || this.options.corsConfig.credentials, - exposedHeaders: this.options.corsConfig.exposedHeaders, - }; - } - - // Route creation - const routeMethod = method === 'ANY' ? '*' : method; - - const state = this.options.disableCookieValidation ? { - parse: false, - failAction: 'ignore', - } : { - parse: true, - failAction: 'error', - }; - - const routeConfig = { - cors, - auth: authStrategyName, - timeout: { socket: false }, - state, - }; - - // skip HEAD routes as hapi will fail with 'Method name not allowed: HEAD ...' - // for more details, check https://github.com/dherault/serverless-offline/issues/204 - if (routeMethod === 'HEAD') { - this.serverlessLog('HEAD method event detected. Skipping HAPI server route mapping ...'); + this.apiGatewayWebSocket._createWsAction(fun, funName, servicePath, funOptions, event); return; } - if (routeMethod !== 'HEAD' && routeMethod !== 'GET') { - // maxBytes: Increase request size from 1MB default limit to 10MB. - // Cf AWS API GW payload limits. - routeConfig.payload = { parse: false, maxBytes: 1024 * 1024 * 10 }; - } - - this.server.route({ - method: routeMethod, - path: fullPath, - config: routeConfig, - handler: (request, h) => { // Here we go - // Store current request as the last one - this.lastRequestOptions = { - method: request.method, - url: request.url.href, - headers: request.headers, - payload: request.payload, - }; - - if (request.auth.credentials && request.auth.strategy) { - this.lastRequestOptions.auth = request.auth; - } - - // Payload processing - const encoding = detectEncoding(request); - - request.payload = request.payload && request.payload.toString(encoding); - request.rawPayload = request.payload; - - // Headers processing - // Hapi lowercases the headers whereas AWS does not - // so we recreate a custom headers object from the raw request - const headersArray = request.raw.req.rawHeaders; - - // During tests, `server.inject` uses *shot*, a package - // for performing injections that does not entirely mimick - // Hapi's usual request object. rawHeaders are then missing - // Hence the fallback for testing - - // Normal usage - if (headersArray) { - request.unprocessedHeaders = {}; - request.multiValueHeaders = {}; - - for (let i = 0; i < headersArray.length; i += 2) { - request.unprocessedHeaders[headersArray[i]] = headersArray[i + 1]; - request.multiValueHeaders[headersArray[i]] = (request.multiValueHeaders[headersArray[i]] || []).concat(headersArray[i + 1]); - } - } - // Lib testing - else { - request.unprocessedHeaders = request.headers; - } - - // Incomming request message - this.printBlankLine(); - this.serverlessLog(`${method} ${request.path} (λ: ${funName})`); - - // Check for APIKey - if ((protectedRoutes.includes(`${routeMethod}#${fullPath}`) || protectedRoutes.includes(`ANY#${fullPath}`)) && !this.options.noAuth) { - const errorResponse = () => h.response({ message: 'Forbidden' }).code(403).type('application/json').header('x-amzn-ErrorType', 'ForbiddenException'); - - if ('x-api-key' in request.headers) { - const requestToken = request.headers['x-api-key']; - if (requestToken !== this.options.apiKey) { - debugLog(`Method ${method} of function ${funName} token ${requestToken} not valid`); - - return errorResponse(); - } - } - else if (request.auth && request.auth.credentials && 'usageIdentifierKey' in request.auth.credentials) { - const usageIdentifierKey = request.auth.credentials.usageIdentifierKey; - if (usageIdentifierKey !== this.options.apiKey) { - debugLog(`Method ${method} of function ${funName} token ${usageIdentifierKey} not valid`); - - return errorResponse(); - } - } - else { - debugLog(`Missing x-api-key on private function ${funName}`); - - return errorResponse(); - } - } - // Shared mutable state is the root of all evil they say - const requestId = randomId(); - this.requests[requestId] = { done: false }; - this.currentRequestId = requestId; - - const response = h.response(); - const contentType = request.mime || defaultContentType; - - // default request template to '' if we don't have a definition pushed in from serverless or endpoint - const requestTemplate = typeof requestTemplates !== 'undefined' && integration === 'lambda' ? requestTemplates[contentType] : ''; - - // https://hapijs.com/api#route-configuration doesn't seem to support selectively parsing - // so we have to do it ourselves - const contentTypesThatRequirePayloadParsing = ['application/json', 'application/vnd.api+json']; - if (contentTypesThatRequirePayloadParsing.includes(contentType) && request.payload && request.payload.length > 1) { - try { - if (!request.payload || request.payload.length < 1) { - request.payload = '{}'; - } - - request.payload = JSON.parse(request.payload); - } - catch (err) { - debugLog('error in converting request.payload to JSON:', err); - } - } - - debugLog('requestId:', requestId); - debugLog('contentType:', contentType); - debugLog('requestTemplate:', requestTemplate); - debugLog('payload:', request.payload); - - /* HANDLER LAZY LOADING */ - - let userHandler; // The lambda function - Object.assign(process.env, this.originalEnvironment); - - try { - if (this.options.noEnvironment) { - // This evict errors in server when we use aws services like ssm - const baseEnvironment = { - AWS_REGION: 'dev', - }; - if (!process.env.AWS_PROFILE) { - baseEnvironment.AWS_ACCESS_KEY_ID = 'dev'; - baseEnvironment.AWS_SECRET_ACCESS_KEY = 'dev'; - } - - process.env = Object.assign(baseEnvironment, process.env); - } - else { - Object.assign( - process.env, - { AWS_REGION: this.service.provider.region }, - this.service.provider.environment, - this.service.functions[key].environment - ); - } - process.env._HANDLER = fun.handler; - userHandler = functionHelper.createHandler(funOptions, this.options); - } - catch (err) { - return this._reply500(response, `Error while loading ${funName}`, err); - } - - /* REQUEST TEMPLATE PROCESSING (event population) */ - - let event = {}; - - if (integration === 'lambda') { - if (requestTemplate) { - try { - debugLog('_____ REQUEST TEMPLATE PROCESSING _____'); - // Velocity templating language parsing - const velocityContext = createVelocityContext(request, this.velocityContextOptions, request.payload || {}); - event = renderVelocityTemplateObject(requestTemplate, velocityContext); - } - catch (err) { - return this._reply500(response, `Error while parsing template "${contentType}" for ${funName}`, err); - } - } - else if (typeof request.payload === 'object') { - event = request.payload || {}; - } - } - else if (integration === 'lambda-proxy') { - event = createLambdaProxyContext(request, this.options, this.velocityContextOptions.stageVariables); - } - - if (event && typeof event === 'object') { - event.isOffline = true; - - if (this.service.custom && this.service.custom.stageVariables) { - event.stageVariables = this.service.custom.stageVariables; - } - else if (integration !== 'lambda-proxy') { - event.stageVariables = {}; - } - } - - debugLog('event:', event); - - return new Promise(resolve => { - // We create the context, its callback (context.done/succeed/fail) will send the HTTP response - const lambdaContext = createLambdaContext(fun, this.service.provider, (err, data, fromPromise) => { - // Everything in this block happens once the lambda function has resolved - debugLog('_____ HANDLER RESOLVED _____'); - - // User should not call context.done twice - if (!this.requests[requestId] || this.requests[requestId].done) { - this.printBlankLine(); - const warning = fromPromise - ? `Warning: handler '${funName}' returned a promise and also uses a callback!\nThis is problematic and might cause issues in your lambda.` - : `Warning: context.done called twice within handler '${funName}'!`; - this.serverlessLog(warning); - debugLog('requestId:', requestId); - - return; - } - - this.requests[requestId].done = true; - - let result = data; - let responseName = 'default'; - const { contentHandling, responseContentType } = endpoint; - - /* RESPONSE SELECTION (among endpoint's possible responses) */ - - // Failure handling - let errorStatusCode = 0; - if (err) { - // Since the --useSeparateProcesses option loads the handler in - // a separate process and serverless-offline communicates with it - // over IPC, we are unable to catch JavaScript unhandledException errors - // when the handler code contains bad JavaScript. Instead, we "catch" - // it here and reply in the same way that we would have above when - // we lazy-load the non-IPC handler function. - if (this.options.useSeparateProcesses && err.ipcException) { - return resolve(this._reply500(response, `Error while loading ${funName}`, err)); - } - - const errorMessage = (err.message || err).toString(); - - const re = /\[(\d{3})]/; - const found = errorMessage.match(re); - if (found && found.length > 1) { - errorStatusCode = found[1]; - } - else { - errorStatusCode = '500'; - } - - // Mocks Lambda errors - result = { - errorMessage, - errorType: err.constructor.name, - stackTrace: this._getArrayStackTrace(err.stack), - }; - - this.serverlessLog(`Failure: ${errorMessage}`); - - if (!this.options.hideStackTraces) { - console.error(err.stack); - } - - for (const key in endpoint.responses) { - if (key !== 'default' && errorMessage.match(`^${endpoint.responses[key].selectionPattern || key}$`)) { - responseName = key; - break; - } - } - } - - debugLog(`Using response '${responseName}'`); - const chosenResponse = endpoint.responses[responseName]; - - /* RESPONSE PARAMETERS PROCCESSING */ - - const responseParameters = chosenResponse.responseParameters; - - if (responseParameters) { - - const responseParametersKeys = Object.keys(responseParameters); - - debugLog('_____ RESPONSE PARAMETERS PROCCESSING _____'); - debugLog(`Found ${responseParametersKeys.length} responseParameters for '${responseName}' response`); - - // responseParameters use the following shape: "key": "value" - Object.entries(responseParametersKeys).forEach(([key, value]) => { - - const keyArray = key.split('.'); // eg: "method.response.header.location" - const valueArray = value.split('.'); // eg: "integration.response.body.redirect.url" - - debugLog(`Processing responseParameter "${key}": "${value}"`); - - // For now the plugin only supports modifying headers - if (key.startsWith('method.response.header') && keyArray[3]) { - - const headerName = keyArray.slice(3).join('.'); - let headerValue; - debugLog('Found header in left-hand:', headerName); - - if (value.startsWith('integration.response')) { - if (valueArray[2] === 'body') { - - debugLog('Found body in right-hand'); - headerValue = (valueArray[3] ? jsonPath(result, valueArray.slice(3).join('.')) : result).toString(); - - } - else { - this.printBlankLine(); - this.serverlessLog(`Warning: while processing responseParameter "${key}": "${value}"`); - this.serverlessLog(`Offline plugin only supports "integration.response.body[.JSON_path]" right-hand responseParameter. Found "${value}" instead. Skipping.`); - this.logPluginIssue(); - this.printBlankLine(); - } - } - else { - headerValue = value.match(/^'.*'$/) ? value.slice(1, -1) : value; // See #34 - } - // Applies the header; - debugLog(`Will assign "${headerValue}" to header "${headerName}"`); - response.header(headerName, headerValue); - - } - else { - this.printBlankLine(); - this.serverlessLog(`Warning: while processing responseParameter "${key}": "${value}"`); - this.serverlessLog(`Offline plugin only supports "method.response.header.PARAM_NAME" left-hand responseParameter. Found "${key}" instead. Skipping.`); - this.logPluginIssue(); - this.printBlankLine(); - } - }); - } - - let statusCode = 200; - - if (integration === 'lambda') { - - const endpointResponseHeaders = (endpoint.response && endpoint.response.headers) || {}; - - Object.entries(endpointResponseHeaders) - .filter(([, value]) => typeof value === 'string' && /^'.*?'$/.test(value)) - .forEach(([key, value]) => response.header(key, value.slice(1, -1))); - - /* LAMBDA INTEGRATION RESPONSE TEMPLATE PROCCESSING */ - - // If there is a responseTemplate, we apply it to the result - const { responseTemplates } = chosenResponse; - - if (typeof responseTemplates === 'object') { - const responseTemplatesKeys = Object.keys(responseTemplates); - - if (responseTemplatesKeys.length) { - - // BAD IMPLEMENTATION: first key in responseTemplates - const responseTemplate = responseTemplates[responseContentType]; - - if (responseTemplate && responseTemplate !== '\n') { - - debugLog('_____ RESPONSE TEMPLATE PROCCESSING _____'); - debugLog(`Using responseTemplate '${responseContentType}'`); - - try { - const reponseContext = createVelocityContext(request, this.velocityContextOptions, result); - result = renderVelocityTemplateObject({ root: responseTemplate }, reponseContext).root; - } - catch (error) { - this.serverlessLog(`Error while parsing responseTemplate '${responseContentType}' for lambda ${funName}:`); - console.log(error.stack); - } - } - } - } - - /* LAMBDA INTEGRATION HAPIJS RESPONSE CONFIGURATION */ - - statusCode = errorStatusCode !== 0 ? errorStatusCode : (chosenResponse.statusCode || 200); - - if (!chosenResponse.statusCode) { - this.printBlankLine(); - this.serverlessLog(`Warning: No statusCode found for response "${responseName}".`); - } - - response.header('Content-Type', responseContentType, { - override: false, // Maybe a responseParameter set it already. See #34 - }); - - response.statusCode = statusCode; - - if (contentHandling === 'CONVERT_TO_BINARY') { - response.encoding = 'binary'; - response.source = Buffer.from(result, 'base64'); - response.variety = 'buffer'; - } - else { - if (result && result.body && typeof result.body !== 'string') { - return this._reply500(response, 'According to the API Gateway specs, the body content must be stringified. Check your Lambda response and make sure you are invoking JSON.stringify(YOUR_CONTENT) on your body object', {}); - } - response.source = result; - } - } - else if (integration === 'lambda-proxy') { - - /* LAMBDA PROXY INTEGRATION HAPIJS RESPONSE CONFIGURATION */ - - response.statusCode = statusCode = (result || {}).statusCode || 200; - - const headers = {}; - if (result && result.headers) { - Object.keys(result.headers).forEach(header => { - headers[header] = (headers[header] || []).concat(result.headers[header]); - }); - } - if (result && result.multiValueHeaders) { - Object.keys(result.multiValueHeaders).forEach(header => { - headers[header] = (headers[header] || []).concat(result.multiValueHeaders[header]); - }); - } - - debugLog('headers', headers); - - Object.keys(headers).forEach(header => { - if (header.toLowerCase() === 'set-cookie') { - headers[header].forEach(headerValue => { - const cookieName = headerValue.slice(0, headerValue.indexOf('=')); - const cookieValue = headerValue.slice(headerValue.indexOf('=') + 1); - h.state(cookieName, cookieValue, { encoding: 'none', strictHeader: false }); - }); - } - else { - headers[header].forEach(headerValue => { - // it looks like Hapi doesn't support multiple headers with the same name, - // appending values is the closest we can come to the AWS behavior. - response.header(header, headerValue, { append: true }); - }); - } - }); - - response.header('Content-Type', 'application/json', { override: false, duplicate: false }); - - if (result && typeof result.body !== 'undefined') { - if (result.isBase64Encoded) { - response.encoding = 'binary'; - response.source = Buffer.from(result.body, 'base64'); - response.variety = 'buffer'; - } - else { - if (result && result.body && typeof result.body !== 'string') { - return this._reply500(response, 'According to the API Gateway specs, the body content must be stringified. Check your Lambda response and make sure you are invoking JSON.stringify(YOUR_CONTENT) on your body object', {}); - } - response.source = result.body; - } - } - } - - // Log response - let whatToLog = result; - - try { - whatToLog = JSON.stringify(result); - } - catch (error) { - // nothing - } - finally { - if (this.options.printOutput) this.serverlessLog(err ? `Replying ${statusCode}` : `[${statusCode}] ${whatToLog}`); - debugLog('requestId:', requestId); - } - - // Bon voyage! - resolve(response); - }); - - // Now we are outside of createLambdaContext, so this happens before the handler gets called: - - // We cannot use Hapijs's timeout feature because the logic above can take a significant time, so we implement it ourselves - this.requests[requestId].timeout = this.options.noTimeout ? null : setTimeout( - this._replyTimeout.bind(this, response, resolve, funName, funOptions.funTimeout, requestId), - funOptions.funTimeout - ); - - // If request body validation is enabled, validate body against the request model. - if (requestBodyValidationModel && !this.options.disableModelValidation) { - try { - requestBodyValidator.validate(requestBodyValidationModel, event.body); - } - catch (error) { - // When request body validation fails, APIG will return back 400 as detailed in: - // https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-request-validation.html - return resolve(this._replyError(400, response, `Invalid request body for '${funName}' handler`, error)); - } - } - - // Finally we call the handler - debugLog('_____ CALLING HANDLER _____'); - - const cleanup = () => { - this._clearTimeout(requestId); - delete this.requests[requestId]; - }; - - let x; - - if (this.options.showDuration) { - performance.mark(`${requestId}-start`); - - const obs = new PerformanceObserver(list => { - for (const entry of list.getEntries()) { - this.serverlessLog(`Duration ${entry.duration.toFixed(2)} ms (λ: ${entry.name})`); - } - - obs.disconnect(); - }); - - obs.observe({ entryTypes: ['measure'] }); - } - - try { - x = userHandler(event, lambdaContext, (err, result) => { - setTimeout(cleanup, 0); - - if (this.options.showDuration) { - performance.mark(`${requestId}-end`); - performance.measure(funName, `${requestId}-start`, `${requestId}-end`); - } - - return lambdaContext.done(err, result); - }); - - // Promise support - if (!this.requests[requestId].done) { - if (x && typeof x.then === 'function') { - x.then(lambdaContext.succeed).catch(lambdaContext.fail).then(cleanup, cleanup); - } - else if (x instanceof Error) { - lambdaContext.fail(x); - } - } - } - catch (error) { - cleanup(); - - return resolve(this._reply500(response, `Uncaught error in your '${funName}' handler`, error)); - } - }); - }, - }); - }); - }); - } - - _extractAuthFunctionName(endpoint) { - const result = authFunctionNameExtractor(endpoint, this.serverlessLog); - - return result.unsupportedAuth ? null : result.authorizerName; - } - - _configureAuthorization(endpoint, funName, method, epath, servicePath, serviceRuntime) { - if (!endpoint.authorizer) { - return null; - } - - const authFunctionName = this._extractAuthFunctionName(endpoint); - - if (!authFunctionName) { - return null; - } - - this.serverlessLog(`Configuring Authorization: ${endpoint.path} ${authFunctionName}`); - - const authFunction = this.service.getFunction(authFunctionName); - - if (!authFunction) return this.serverlessLog(`WARNING: Authorization function ${authFunctionName} does not exist`); - - const authorizerOptions = { - resultTtlInSeconds: '300', - identitySource: 'method.request.header.Authorization', - identityValidationExpression: '(.*)', - }; - - if (typeof endpoint.authorizer === 'string') { - authorizerOptions.name = authFunctionName; - } - else { - Object.assign(authorizerOptions, endpoint.authorizer); - } - - // Create a unique scheme per endpoint - // This allows the methodArn on the event property to be set appropriately - const authKey = `${funName}-${authFunctionName}-${method}-${epath}`; - const authSchemeName = `scheme-${authKey}`; - const authStrategyName = `strategy-${authKey}`; // set strategy name for the route config - - debugLog(`Creating Authorization scheme for ${authKey}`); - - // Create the Auth Scheme for the endpoint - const scheme = createAuthScheme( - authFunction, - authorizerOptions, - authFunctionName, - epath, - this.options, - this.serverlessLog, - servicePath, - serviceRuntime, - this.serverless - ); - - // Set the auth scheme and strategy on the server - this.server.auth.scheme(authSchemeName, scheme); - this.server.auth.strategy(authStrategyName, authSchemeName); - - return authStrategyName; - } - - // All done, we can listen to incomming requests - async _listen() { - try { - await this.server.start(); - } - catch (e) { - console.error('Unexpected error while starting serverless-offline server:', e); - process.exit(1); - } - - this.printBlankLine(); - this.serverlessLog(`Offline listening on http${this.options.httpsProtocol ? 's' : ''}://${this.options.host}:${this.options.port}`); - this.serverlessLog('Enter "rp" to replay the last request'); - - process.openStdin().addListener('data', data => { - // note: data is an object, and when converted to a string it will - // end with a linefeed. so we (rather crudely) account for that - // with toString() and then trim() - if (data.toString().trim() === 'rp') { - this._injectLastRequest(); - } - }); - - return this.server; - } - - end() { - this.serverlessLog('Halting offline server'); - functionHelper.cleanup(); - this.server.stop({ timeout: 5000 }) - .then(() => process.exit(this.exitCode)); - } - - _injectLastRequest() { - if (this.lastRequestOptions) { - this.serverlessLog('Replaying last request'); - this.server.inject(this.lastRequestOptions); - } - else { - this.serverlessLog('No last request to replay!'); - } - } - - // Bad news - _replyError(responseCode, response, message, error) { - const stackTrace = this._getArrayStackTrace(error.stack); - - this.serverlessLog(message); - - console.error(error); - - response.header('Content-Type', 'application/json'); - - /* eslint-disable no-param-reassign */ - response.statusCode = responseCode; - response.source = { - errorMessage: message, - errorType: error.constructor.name, - stackTrace, - offlineInfo: 'If you believe this is an issue with serverless-offline please submit it, thanks. https://github.com/dherault/serverless-offline/issues', - }; - /* eslint-enable no-param-reassign */ - - return response; - } - - _reply500(response, message, error) { - // APIG replies 200 by default on failures - return this._replyError(200, response, message, error); - } - - _replyTimeout(response, resolve, funName, funTimeout, requestId) { - if (this.currentRequestId !== requestId) return; - - this.serverlessLog(`Replying timeout after ${funTimeout}ms`); - /* eslint-disable no-param-reassign */ - response.statusCode = 503; - response.source = `[Serverless-Offline] Your λ handler '${funName}' timed out after ${funTimeout}ms.`; - /* eslint-enable no-param-reassign */ - resolve(response); - } - - _clearTimeout(requestId) { - const { timeout } = this.requests[requestId] || {}; - clearTimeout(timeout); - } - - _createResourceRoutes() { - if (!this.options.resourceRoutes) return true; - const resourceRoutesOptions = this.options.resourceRoutes; - const resourceRoutes = parseResources(this.service.resources); - - if (!resourceRoutes || !Object.keys(resourceRoutes).length) return true; - - this.printBlankLine(); - this.serverlessLog('Routes defined in resources:'); - - Object.entries(resourceRoutes).forEach(([methodId, resourceRoutesObj]) => { - const { isProxy, method, path, pathResource, proxyUri } = resourceRoutesObj; - - if (!isProxy) { - return this.serverlessLog(`WARNING: Only HTTP_PROXY is supported. Path '${pathResource}' is ignored.`); - } - if (!path) { - return this.serverlessLog(`WARNING: Could not resolve path for '${methodId}'.`); - } - - let fullPath = this.options.prefix + (pathResource.startsWith('/') ? pathResource.slice(1) : pathResource); - if (fullPath !== '/' && fullPath.endsWith('/')) fullPath = fullPath.slice(0, -1); - fullPath = fullPath.replace(/\+}/g, '*}'); - - const proxyUriOverwrite = resourceRoutesOptions[methodId] || {}; - const proxyUriInUse = proxyUriOverwrite.Uri || proxyUri; - - if (!proxyUriInUse) { - return this.serverlessLog(`WARNING: Could not load Proxy Uri for '${methodId}'`); - } - - const routeMethod = method === 'ANY' ? '*' : method; - const routeConfig = { cors: this.options.corsConfig }; - - // skip HEAD routes as hapi will fail with 'Method name not allowed: HEAD ...' - // for more details, check https://github.com/dherault/serverless-offline/issues/204 - if (routeMethod === 'HEAD') { - this.serverlessLog('HEAD method event detected. Skipping HAPI server route mapping ...'); - - return; - } - - if (routeMethod !== 'HEAD' && routeMethod !== 'GET') { - routeConfig.payload = { parse: false }; - } - - this.serverlessLog(`${method} ${fullPath} -> ${proxyUriInUse}`); - this.server.route({ - method: routeMethod, - path: fullPath, - config: routeConfig, - handler: (request, h) => { - const { params } = request; - let resultUri = proxyUriInUse; - - Object.entries(params).forEach(([key, value]) => { - resultUri = resultUri.replace(`{${key}}`, value); - }); - - if (request.url.search !== null) { - resultUri += request.url.search; // search is empty string by default - } - - this.serverlessLog(`PROXY ${request.method} ${request.url.path} -> ${resultUri}`); + if (!event.http) return; - return h.proxy({ uri: resultUri, passThrough: true }); - }, + this.apiGateway._createRoutes(event, funOptions, protectedRoutes, funName, servicePath, serviceRuntime, defaultContentType, key, fun); }); }); } +}; - _create404Route() { - // If a {proxy+} route exists, don't conflict with it - if (this.server.match('*', '/{p*}')) return; - - this.server.route({ - method: '*', - path: '/{p*}', - config: { cors: this.options.corsConfig }, - handler: (request, h) => { - const response = h.response({ - statusCode: 404, - error: 'Serverless-offline: route not found.', - currentRoute: `${request.method} - ${request.path}`, - existingRoutes: this.server.table() - .filter(route => route.path !== '/{p*}') // Exclude this (404) route - .sort((a, b) => a.path <= b.path ? -1 : 1) // Sort by path - .map(route => `${route.method} - ${route.path}`), // Human-friendly result - }); - response.statusCode = 404; +let experimentalWarningNotified = false; - return response; - }, - }); +function experimentalWebSocketSupportWarning() { + // notify only once + if (experimentalWarningNotified) { + return; } - _getArrayStackTrace(stack) { - if (!stack) return null; + console.warn('WebSocket support in serverless-offline is experimental.\nFor any bugs, missing features or other feedback file an issue at https://github.com/dherault/serverless-offline/issues'); - const splittedStack = stack.split('\n'); - - return splittedStack.slice(0, splittedStack.findIndex(item => item.match(/server.route.handler.createLambdaContext/))).map(line => line.trim()); - } - - _logAndExit() { - // eslint-disable-next-line - console.log.apply(null, arguments); - process.exit(0); - } + experimentalWarningNotified = true; } // Serverless exits with code 1 when a promise rejection is unhandled. Not AWS. // Users can still use their own unhandledRejection event though. process.removeAllListeners('unhandledRejection'); - -module.exports = Offline; diff --git a/src/utils.js b/src/utils.js index 85770f04f..9ba07d491 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,12 +1,11 @@ 'use strict'; +const cuid = require('cuid'); const { createHash } = require('crypto'); module.exports = { toPlainOrEmptyObject: obj => typeof obj === 'object' && !Array.isArray(obj) ? obj : {}, - randomId: () => Math.random().toString(10).slice(2), - nullIfEmpty: o => o && (Object.keys(o).length > 0 ? o : null), isPlainObject: obj => typeof obj === 'object' && !Array.isArray(obj), @@ -43,4 +42,8 @@ module.exports = { createDefaultApiKey() { return createHash('md5').digest('hex'); }, + + createUniqueId() { + return cuid(); + }, }; diff --git a/src/websocketHelpers.js b/src/websocketHelpers.js new file mode 100644 index 000000000..531f310c9 --- /dev/null +++ b/src/websocketHelpers.js @@ -0,0 +1,111 @@ +const { DateTime } = require('luxon'); +const { createUniqueId } = require('./utils'); + +// TODO this should be probably moved to utils, and combined with other header +// functions and utilities +function createMultiValueHeaders(headers) { + return Object.entries(headers).reduce((acc, [key, value]) => { + acc[key] = [value]; + + return acc; + }, {}); +} + +// CLF -> Common Log Format +// https://httpd.apache.org/docs/1.3/logs.html#common +// [day/month/year:hour:minute:second zone] +// day = 2*digit +// month = 3*letter +// year = 4*digit +// hour = 2*digit +// minute = 2*digit +// second = 2*digit +// zone = (`+' | `-') 4*digit +function formatToClfTime(date) { + return DateTime.fromJSDate(date).toFormat('dd/MMM/yyyy:HH:mm:ss ZZZ'); +} + +const createRequestContext = (action, eventType, connection) => { + const now = new Date(); + + const requestContext = { + apiId: 'private', + connectedAt: connection.connectionTime, + connectionId:connection.connectionId, + domainName: 'localhost', + eventType, + extendedRequestId: `${createUniqueId()}`, + identity: { + accountId: null, + accessKey: null, + caller: null, + cognitoAuthenticationProvider: null, + cognitoAuthenticationType: null, + cognitoIdentityId: null, + cognitoIdentityPoolId: null, + principalOrgId: null, + sourceIp: '127.0.0.1', + user: null, + userAgent: null, + userArn: null, + }, + messageDirection: 'IN', + messageId: `${createUniqueId()}`, + requestId: `${createUniqueId()}`, + requestTime: formatToClfTime(now), + requestTimeEpoch: now.getTime(), + routeKey: action, + stage: 'local', + }; + + return requestContext; +}; + +exports.createEvent = (action, eventType, connection, payload) => { + const event = { + body: JSON.stringify(payload), + isBase64Encoded: false, + requestContext: createRequestContext(action, eventType, connection), + }; + + return event; +}; + +exports.createConnectEvent = (action, eventType, connection, options) => { + const headers = { + Host: 'localhost', + 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits', + 'Sec-WebSocket-Key': `${createUniqueId()}`, + 'Sec-WebSocket-Version': '13', + 'X-Amzn-Trace-Id': `Root=${createUniqueId()}`, + 'X-Forwarded-For': '127.0.0.1', + 'X-Forwarded-Port': `${options.port + 1}`, + 'X-Forwarded-Proto': `http${options.httpsProtocol ? 's' : ''}`, + }; + const multiValueHeaders = createMultiValueHeaders(headers); + const event = { + headers, + isBase64Encoded: false, + multiValueHeaders, + requestContext: createRequestContext(action, eventType, connection), + }; + + return event; +}; + +exports.createDisconnectEvent = (action, eventType, connection) => { + const headers = { + Host: 'localhost', + 'x-api-key': '', + 'x-restapi': '', + }; + const multiValueHeaders = createMultiValueHeaders(headers); + const event = { + headers, + isBase64Encoded: false, + multiValueHeaders, + requestContext: createRequestContext(action, eventType, connection), + }; + + return event; +};