├── .eslintrc.json ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── README.md ├── authorizer-gen ├── index.js ├── index.oldtest.js ├── utils.js └── utils.oldtest.js ├── bundler ├── amazon │ └── index.js ├── google │ └── index.js ├── utils.js └── utils.test.js ├── cli.js ├── copier └── index.js ├── deployer ├── amazon │ ├── index.js │ ├── index.test.js │ └── utils.js └── google │ ├── index.js │ └── index.test.js ├── discoverer └── index.js ├── hyperform-banner.png ├── index.js ├── index.test.js ├── initer ├── index.js └── index.test.js ├── kindler ├── index.js └── index.tes.js ├── meta └── index.js ├── package-lock.json ├── package.json ├── parser └── index.js ├── printers └── index.js ├── publisher └── amazon │ ├── index.js │ ├── utils.js │ └── utils.test.js ├── response-collector ├── .gitignore └── index.js ├── schemas ├── index.js └── index.test.js ├── surveyor └── index.js ├── template ├── .eslintrc.json └── index.js ├── transpiler └── index.js ├── uploader ├── amazon │ ├── index.js │ └── index.test.js └── google │ └── index.js └── zipper ├── google └── index.js ├── index.js └── index.test.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2021": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "airbnb-base" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": 12 13 | }, 14 | "rules": { 15 | "no-console": "off", 16 | "no-trailing-spaces": "off", 17 | "semi": "off", 18 | "object-shorthand": "off", 19 | "no-unused-vars": "warn", 20 | "no-useless-catch": "warn", 21 | "no-underscore-dangle": "off", 22 | "no-await-in-loop": "warn", 23 | "no-else-return": "off", 24 | "camelcase": "off", 25 | "no-restricted-syntax": "warn", 26 | "prefer-destructuring": "warn", 27 | "no-continue": "warn" 28 | } 29 | } -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | # pull_request: 10 | # branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | strategy: 16 | matrix: 17 | platform: [ubuntu-latest] 18 | runs-on: ${{ matrix.platform }} 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js 10.x 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: 10.x 26 | - name: Install 27 | run: npm ci # beware of dashes (-) before run 28 | - name: Test 29 | run: npm run test 30 | env: # env is only kept in this step 31 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 32 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 33 | AWS_REGION: ${{ secrets.AWS_REGION }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dde/lamb/ 4 | dde/lambs/ 5 | node_modules/ 6 | ultra/zoo/ 7 | dist/ 8 | hyperform.json 9 | .env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Hyperform 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ![Hyperform Banner](https://github.com/qngapparat/hyperform/blob/master/hyperform-banner.png) 4 | 5 | 6 | > ⚡ Lightweight serverless framework for NodeJS 7 | 8 | * **Unopinionated** (Any JS code works) 9 | * **Lightweight** (no wrapping) 10 | * **1-click deploy** (1 command) 11 | * **Multi-Cloud** (for AWS & Google Cloud) 12 | * **Maintains** (provider's conventions) 13 | 14 | 15 | ## Install 16 | 17 | ```sh 18 | $ npm install -g hyperform-cli 19 | ``` 20 | 21 | 22 | 23 | ## Usage 24 | 25 | 26 | * Everything works like a normal NodeJS app. You can use NPM packages, external files, assets, since the entire folder containing `hyperform.json` is included with each function. 27 | 28 | ### AWS Lambda 29 | 30 | 31 | ```js 32 | // somefile.js 33 | 34 | // AWS Lambda uses 'event', 'context', and 'callback' convention 35 | // Learn more: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html 36 | 37 | exports.foo = (event, context, callback) => { 38 | context.succeed({ 39 | message: "I'm Foo on AWS Lambda!" 40 | }) 41 | } 42 | 43 | exports.bar = (event, context, callback) => { 44 | context.succeed({ 45 | message: "I'm Bar on AWS Lambda!" 46 | }) 47 | } 48 | 49 | // ... 50 | ``` 51 | 52 | Create a `hyperform.json` in the current folder, with your AWS credentials: 53 | 54 | ```json 55 | { 56 | "amazon": { 57 | "aws_access_key_id": "...", 58 | "aws_secret_access_key": "...", 59 | "aws_region": "..." 60 | } 61 | } 62 | ``` 63 | 64 | In the terminal, type: 65 | 66 | ``` 67 | $ hyperform deploy somefile.js --amazon --url 68 | > 🟢 foo https://w3g434h.execute-api.us-east-2.amazonaws.com/foo 69 | > 🟢 bar https://w3g434h.execute-api.us-east-2.amazonaws.com/bar 70 | 71 | ``` 72 | 73 | ... and your functions are deployed & invocable via `GET` and `POST`. 74 | 75 | 76 | ### Google Cloud Functions 77 | 78 | 79 | ```js 80 | // somefile.js 81 | 82 | // Google Cloud uses Express's 'Request' and 'Response' convention 83 | // Learn more: https://expressjs.com/en/api.html#req 84 | // https://expressjs.com/en/api.html#res 85 | 86 | exports.foo = (req, res) => { 87 | let message = req.query.message || req.body.message || "I'm a Google Cloud Function, Foo"; 88 | res.status(200).send(message); 89 | }; 90 | 91 | exports.bar = (req, res) => { 92 | let message = req.query.message || req.body.message || "I'm a Google Cloud Function, Bar"; 93 | res.status(200).send(message); 94 | }; 95 | ``` 96 | 97 | 98 | Create a `hyperform.json` in the current folder with your Google Cloud credentials: 99 | 100 | ```json 101 | { 102 | "google": { 103 | "gc_project": "...", 104 | "gc_region": "...", 105 | } 106 | } 107 | ``` 108 | 109 | In the terminal, type: 110 | 111 | ``` 112 | $ hyperform deploy somefile.js --google --url 113 | > 🟢 foo https://us-central1-someproject-153dg2.cloudfunctions.net/foo 114 | > 🟢 bar https://us-central1-someproject-153dg2.cloudfunctions.net/bar 115 | 116 | ``` 117 | 118 | ... and your functions are deployed & invocable via `GET` and `POST`. 119 | 120 | ## Hints & Caveats 121 | 122 | * New functions are deployed with 256MB RAM, 60s timeouts 123 | * The flag `--url` creates **unprotected** URLs to the functions. Anyone with these URLs can invoke your functions 124 | * The entire folder containing `hyperform.json` will be deployed with each function, so you can use NPM packages, external files (...) just like normal. 125 | 126 | 127 | 128 | ### FAQ 129 | 130 | **Where are functions deployed to?** 131 | 132 | * On AWS: To AWS Lambda 133 | * On Google Cloud: To Google Cloud Functions 134 | 135 | **Where does deployment happen?** 136 | 137 | It's a client-side tool, so on your computer. It uses the credentials it finds in `hyperform.json` 138 | 139 | 140 | **Can I use NPM packages, external files, (...) ?** 141 | 142 | Yes. The entire folder where `hyperform.json` is is uploaded, excluding `.git`, `.gitignore`, `hyperform.json`, and for Google Cloud `node_modules` (Google Cloud installs NPM dependencies freshly from `package.json`). So everything works like a normal NodeJS app. 143 | 144 | **How does `--url` create URLs?** 145 | 146 | On AWS, it creates an API Gateway API (called `hf`), and a `GET` and `POST` route to your function. 147 | 148 | On Google Cloud, it removes IAM checking from the function by adding `allUsers` to the group "Cloud Functions Invoker" of that function. 149 | 150 | Note that in both cases, **anyone with the URL can invoke your function. Make sure to add Authentication logic inside your function**, if needed. 151 | 152 | 153 | 154 | ## Opening Issues 155 | 156 | Feel free to open issues if you find bugs. 157 | 158 | ## Contributing 159 | 160 | Always welcome ❤️ Please see CONTRIBUTING.md 161 | 162 | ## License 163 | 164 | Apache 2.0 165 | -------------------------------------------------------------------------------- /authorizer-gen/index.js: -------------------------------------------------------------------------------- 1 | // const AWS = require('aws-sdk') 2 | // const { deployAmazon } = require('../deployer/amazon/index') 3 | // const { allowApiGatewayToInvokeLambda } = require('../publisher/amazon/utils') 4 | // const { zip } = require('../zipper/index') 5 | // const { ensureBearerTokenSecure } = require('./utils') 6 | // const { logdev } = require('../printers/index') 7 | 8 | // AWS.config.update({ 9 | // accessKeyId: process.env.AWS_ACCESS_KEY_ID, 10 | // secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 11 | // region: process.env.AWS_REGION, 12 | // }) 13 | 14 | // /** 15 | // * @description Creates or updates Authorizer lambda with name "authorizerName" 16 | // * that if used as Authorizer in API Gateway, will 17 | // * greenlight requests with given expectedBearer token 18 | // * @param {string} authorizerName For example 'myfn-authorizer' 19 | // * @param {string} expectedBearer The 'Authorization': 'Bearer ...' token 20 | // * the Authorizer will greenlight 21 | // * @param {{region: string}} options 22 | // * @returns {Promise} ARN of the deployed authorizer lambda 23 | // */ 24 | // async function deployAuthorizerLambda(authorizerName, expectedBearer, options) { 25 | // if (options == null || options.region == null) { 26 | // throw new Error('optionsregion is required') // TODO HF programmer mistake 27 | // } 28 | 29 | // // will mess up weird user-given Tokens but that's on the user 30 | // // will lead to false negatives (still better than false positives or injections) 31 | // // This should not be needed, as expectedBearer is generated by our code, but to be sure 32 | // const sanitizedExpectedBearer = encodeURI(expectedBearer) 33 | // ensureBearerTokenSecure(sanitizedExpectedBearer) 34 | 35 | // const authorizerCode = ` 36 | // exports.handler = async(event) => { 37 | // const expected = \`Bearer ${sanitizedExpectedBearer}\` 38 | // const isAuthorized = (event.headers.authorization === expected) 39 | // return { 40 | // isAuthorized 41 | // } 42 | // }; 43 | // ` 44 | // // TODO do this private somehow, in RAM, so that no program can tamper with authorizer zip 45 | // const zipPath = await zip(authorizerCode) 46 | 47 | // const deployOptions = { 48 | // name: authorizerName, 49 | // timeout: 1, // 1 second is ample time 50 | // handler: 'index.handler', 51 | // region: options.region, 52 | // } 53 | 54 | // // create or update Authorizer Lambda 55 | // const authorizerArn = await deployAmazon(zipPath, deployOptions) 56 | 57 | // await allowApiGatewayToInvokeLambda(authorizerName, options.region) 58 | 59 | // return authorizerArn 60 | // } 61 | 62 | // /** 63 | // * @description Gets the RouteId of a route belonging to an API on API Gateway 64 | // * @param {string} apiId Id of the API in API Gateway 65 | // * @param {string} routeKey For example '$default' 66 | // * @param {string} region Region of the API in API Gateway 67 | // * @returns {Promise} RouteId of the route 68 | // * @throws If query items did not include a Route named "routeKey" 69 | // */ 70 | // async function getRouteId(apiId, routeKey, region) { 71 | // const apigatewayv2 = new AWS.ApiGatewayV2({ 72 | // apiVersion: '2018-11-29', 73 | // region: region, 74 | // }) 75 | 76 | // // TODO Amazon might return a paginated response here (?) 77 | // // In that case with many routes, the route we look for may not be on first page 78 | // const params = { 79 | // ApiId: apiId, 80 | // MaxResults: '9999', // string according to docs and it works... uuh? 81 | // } 82 | 83 | // const res = await apigatewayv2.getRoutes(params).promise() 84 | 85 | // const matchingRoutes = res.Items.filter((item) => item.RouteKey === routeKey) 86 | // if (matchingRoutes.length === 0) { 87 | // throw new Error(`Could not get RouteId of apiId, routeKey ${apiId}, ${routeKey}`) 88 | // } 89 | 90 | // // just take first one 91 | // // Hyperform convention is there's only one with any given name 92 | // const routeId = matchingRoutes[0].RouteId 93 | 94 | // return routeId 95 | // } 96 | 97 | // /** 98 | // * @description Sets the $default path of "apiId" to be guarded by "authorizerArn" lambda. 99 | // * @param {string} apiId Id of API in API Gateway to be guarded 100 | // * @param {string} authorizerArn ARN of Lambda that should act as the authorizer 101 | // * @returns {void} 102 | // * @throws Throws if authorizerArn is not formed like a Lambda ARN. 103 | // * Fails silently if authorizerArn Lambda does not exist. 104 | // */ 105 | // async function setDefaultRouteAuthorizer(apiId, authorizerArn, apiRegion) { 106 | // // TODO what happens when api (set to REGIONAL) and authorizer lambda are in different regions 107 | 108 | // // region is the fourth field 109 | // const authorizerRegion = authorizerArn.split(':')[3] 110 | // // name is the last field 111 | // const authorizerName = authorizerArn.split(':').slice(-1)[0] 112 | 113 | // const authorizerType = 'REQUEST' 114 | // const identitySource = '$request.header.Authorization' 115 | 116 | // const authorizerUri = `arn:aws:apigateway:${authorizerRegion}:lambda:path/2015-03-31/functions/${authorizerArn}/invocations` 117 | 118 | // // Try to create authorizer for that API 119 | // // succeeds => Authorizer with that name did not exist yet. Use that authorizerId going forward 120 | // // Fails => Authorizer already existed with that name. 121 | // // Get that one's authorizerId (Follow Hyperform conv: same name - assume identical) 122 | 123 | // const apigatewayv2 = new AWS.ApiGatewayV2({ 124 | // apiVersion: '2018-11-29', 125 | // region: apiRegion, 126 | // }) 127 | 128 | // const createAuthorizerParams = { 129 | // ApiId: apiId, 130 | // Name: authorizerName, 131 | // AuthorizerType: authorizerType, 132 | // IdentitySource: [identitySource], 133 | // AuthorizerUri: authorizerUri, 134 | // AuthorizerPayloadFormatVersion: '2.0', 135 | // EnableSimpleResponses: true, 136 | // } 137 | 138 | // let authorizerId 139 | 140 | // try { 141 | // const createRes = await apigatewayv2.createAuthorizer(createAuthorizerParams).promise() 142 | // // authorizer does not exist 143 | // authorizerId = createRes.AuthorizerId 144 | // } catch (e) { 145 | // if (e.code === 'BadRequestException') { 146 | // // authorizer already exists 147 | // // TODO update authorizer to make sure it points 148 | // // ...to authorizerArn lambda (to behave exactly as stated in @description) 149 | 150 | // // TODO pull-up this and/or add update authorizer 151 | 152 | // // obtain its id 153 | // const getAuthorizersParams = { 154 | // ApiId: apiId, 155 | // MaxResults: '9999', 156 | // } 157 | // const getRes = await apigatewayv2.getAuthorizers(getAuthorizersParams).promise() 158 | 159 | // const matchingRoutes = getRes.Items.filter((item) => item.Name === authorizerName) 160 | // if (matchingRoutes.length === 0) { 161 | // throw new Error(`Could not get AuthorizerId of apiId ${apiId}`) 162 | // } 163 | 164 | // // just take first one 165 | // // Hyperform convention is there's only one with any given name 166 | // authorizerId = matchingRoutes[0].AuthorizerId 167 | // } else { 168 | // // some other error 169 | // throw e 170 | // } 171 | // } 172 | 173 | // // attach authorizer to $default 174 | // const routeKey = '$default' 175 | // const routeId = await getRouteId(apiId, routeKey, apiRegion) 176 | 177 | // const updateRouteParams = { 178 | // ApiId: apiId, 179 | // RouteId: routeId, 180 | // AuthorizerId: authorizerId, 181 | // AuthorizationType: 'CUSTOM', 182 | // } 183 | // await apigatewayv2.updateRoute(updateRouteParams).promise() 184 | // logdev('set authorizer') 185 | // // done 186 | // } 187 | 188 | // /** 189 | // * @description Detaches the current authorizer, if any, from the $default route of API 190 | // * with ID "apiId". The route is then in any case unauthorized and the underlying Lambda becomes 191 | // * invokable by anyone with the URL. 192 | // * This does not delete the authorizer or the authorizer Lambda. 193 | // * @param {string} apiId 194 | // * @param {string} apiRegion 195 | // */ 196 | // async function detachDefaultRouteAuthorizer(apiId, apiRegion) { 197 | // const apigatewayv2 = new AWS.ApiGatewayV2({ 198 | // apiVersion: '2018-11-29', 199 | // region: apiRegion, 200 | // }) 201 | 202 | // const routeKey = '$default' 203 | // const routeId = await getRouteId(apiId, routeKey, apiRegion) 204 | 205 | // const updateRouteParams = { 206 | // ApiId: apiId, 207 | // RouteId: routeId, 208 | // AuthorizationType: 'NONE', 209 | // } 210 | 211 | // await apigatewayv2.updateRoute(updateRouteParams).promise() 212 | // logdev('detached authorizer ') 213 | // } 214 | // // TODO set authorizer cache ?? 215 | 216 | // module.exports = { 217 | // deployAuthorizerLambda, 218 | // setDefaultRouteAuthorizer, 219 | // detachDefaultRouteAuthorizer, 220 | // _only_for_testing_getRouteId: getRouteId, 221 | // } 222 | -------------------------------------------------------------------------------- /authorizer-gen/index.oldtest.js: -------------------------------------------------------------------------------- 1 | // /* eslint-disable global-require */ 2 | // const LAMBDANAME = 'jest-reserved-authorizer' 3 | // const LAMBDAREGION = 'us-east-2' 4 | // const APIREGION = 'us-east-2' 5 | // const LAMBDAARN = 'arn:aws:lambda:us-east-2:735406098573:function:jest-reserved-authorizer' 6 | // const BEARERTOKEN = 'somelengthyrandombearertoken1234567890' 7 | 8 | // // NOTE Currently convention is one API per endpoint 9 | // // Don't extend tests until we are sure of this convention / committed to 10 | 11 | // describe('authorizer-gen', () => { 12 | // describe('index', () => { 13 | // describe('getRouteId', () => { 14 | // test('returns non-empty string for existing route', async () => { 15 | // const getRouteId = require('./index')._only_for_testing_getRouteId 16 | 17 | // const apiId = 'vca3i8138h' // first-http-api in my API Gateway 18 | // const routeKey = '$default' 19 | 20 | // let err 21 | // let res 22 | // try { 23 | // res = await getRouteId(apiId, routeKey, LAMBDAREGION) 24 | // } catch (e) { 25 | // console.log(e) 26 | // err = e 27 | // } 28 | 29 | // // It did not throw 30 | // expect(err).not.toBeDefined() 31 | // // Returned routeid: a non-empty string 32 | // expect(res).toBeDefined() 33 | // expect(typeof res).toEqual('string') 34 | // expect(res.length > 0).toEqual(true) 35 | // }) 36 | 37 | // test('throws for non-existing API', async () => { 38 | // const getRouteId = require('./index')._only_for_testing_getRouteId 39 | 40 | // const apiId = 'invalid-api-id' 41 | // const routeKey = '$default' 42 | 43 | // let err 44 | // let res 45 | // try { 46 | // res = await getRouteId(apiId, routeKey, LAMBDAREGION) 47 | // } catch (e) { 48 | // err = e 49 | // } 50 | // // It threw 51 | // expect(err).toBeDefined() 52 | // expect(err.toString()).toMatch(/Invalid API identifier/) 53 | // }) 54 | 55 | // test('throws for non-existing route', async () => { 56 | // const getRouteId = require('./index')._only_for_testing_getRouteId 57 | 58 | // const apiId = 'vca3i8138h' // first-http-api in my API Gateway 59 | // const routeKey = 'invalid-route-name' 60 | 61 | // let err 62 | // let res 63 | // try { 64 | // res = await getRouteId(apiId, routeKey, LAMBDAREGION) 65 | // } catch (e) { 66 | // err = e 67 | // } 68 | 69 | // // It threw 70 | // expect(err).toBeDefined() 71 | // }) 72 | // }) 73 | 74 | // describe('deployAuthorizerLambda', () => { 75 | // test('throws on expectedBearer shorter than 10 digits (not secure)', async () => { 76 | // const { deployAuthorizerLambda } = require('./index') 77 | 78 | // const expectedBearer = '123456789 ' 79 | // const options = { 80 | // region: LAMBDAREGION, 81 | // } 82 | // let err 83 | // try { 84 | // await deployAuthorizerLambda(LAMBDANAME, expectedBearer, options) 85 | // } catch (e) { 86 | // err = e 87 | // } 88 | 89 | // expect(err).toBeDefined() 90 | // }) 91 | 92 | // test('throws on expectedBearer is null', async () => { 93 | // const { deployAuthorizerLambda } = require('./index') 94 | 95 | // const expectedBearer = null 96 | // const options = { 97 | // region: LAMBDAREGION, 98 | // } 99 | // let err 100 | // try { 101 | // await deployAuthorizerLambda(LAMBDANAME, expectedBearer, options) 102 | // } catch (e) { 103 | // err = e 104 | // } 105 | 106 | // expect(err).toBeDefined() 107 | // }) 108 | 109 | // test('throws on expectedBearer is empty string', async () => { 110 | // const { deployAuthorizerLambda } = require('./index') 111 | 112 | // const expectedBearer = ' ' 113 | // const options = { 114 | // region: LAMBDAREGION, 115 | // } 116 | // let err 117 | // try { 118 | // await deployAuthorizerLambda(LAMBDANAME, expectedBearer, options) 119 | // } catch (e) { 120 | // err = e 121 | // } 122 | 123 | // expect(err).toBeDefined() 124 | // }) 125 | 126 | // // allow 15 seconds 127 | 128 | // test('completes if authorizer lambda does not exist yet, returns ARN', async () => { 129 | // const { deployAuthorizerLambda } = require('./index') 130 | // const { deleteAmazon } = require('../deployer/amazon/index') 131 | 132 | // const expectedBearer = BEARERTOKEN 133 | // const options = { 134 | // region: LAMBDAREGION, 135 | // } 136 | 137 | // /// ////////////////////////////////////////////// 138 | // // Setup: delete authorizer if it exists already 139 | 140 | // try { 141 | // await deleteAmazon(LAMBDANAME, LAMBDAREGION) 142 | // // deleted lambda 143 | // } catch (e) { 144 | // if (e.code === 'ResourceNotFoundException') { 145 | // // does not exist in the first place, nice 146 | // } else { 147 | // throw e 148 | // } 149 | // } 150 | 151 | // /// /////////////////////////////////////// 152 | 153 | // let err 154 | // let res 155 | // try { 156 | // res = await deployAuthorizerLambda(LAMBDANAME, expectedBearer, options) 157 | // } catch (e) { 158 | // console.log(e) 159 | // err = e 160 | // } 161 | 162 | // expect(err).not.toBeDefined() 163 | // expect(typeof res).toBe('string') 164 | // }, 30 * 1000) 165 | 166 | // test('completes if authorizer lambda exists already, returns ARN', async () => { 167 | // const { deployAuthorizerLambda } = require('./index') 168 | 169 | // const expectedBearer = BEARERTOKEN 170 | // const options = { 171 | // region: LAMBDAREGION, 172 | // } 173 | 174 | // /// /////////////////////////////////////// 175 | // // Setup: ensure authorizer lambda exists 176 | 177 | // await deployAuthorizerLambda(LAMBDANAME, expectedBearer, options) 178 | 179 | // /// /////////////////////////////////////// 180 | 181 | // let err 182 | // let res 183 | // try { 184 | // res = await deployAuthorizerLambda(LAMBDANAME, expectedBearer, options) 185 | // } catch (e) { 186 | // console.log(e) 187 | // err = e 188 | // } 189 | 190 | // expect(err).not.toBeDefined() 191 | // expect(typeof res).toBe('string') 192 | // }, 30 * 1000) 193 | // }) 194 | 195 | // // allow 15 seconds 196 | // describe('setDefaultRouteAuthorizer', () => { 197 | // test('completes when authorizer exists already', async () => { 198 | // const { setDefaultRouteAuthorizer } = require('./index') 199 | 200 | // const apiId = 'vca3i8138h' // first-http-api in my API Gateway 201 | 202 | // let err 203 | // try { 204 | // await setDefaultRouteAuthorizer(apiId, LAMBDAARN, APIREGION) 205 | // } catch (e) { 206 | // console.log(e) 207 | // err = e 208 | // } 209 | 210 | // expect(err).not.toBeDefined() 211 | // }, 15 * 1000) 212 | 213 | // test('completes when API has no authorizer yet', async () => { 214 | // const { setDefaultRouteAuthorizer, detachDefaultRouteAuthorizer } = require('./index') 215 | // const apiId = 'vca3i8138h' // first-http-api in my API Gateway 216 | 217 | // /// //////////////////////////////////////////////// 218 | // // Setup: detach current authorizer (if any) 219 | 220 | // await detachDefaultRouteAuthorizer(apiId, APIREGION) 221 | 222 | // /// ///////////////////////////////////////////////// 223 | 224 | // let err 225 | // try { 226 | // await setDefaultRouteAuthorizer(apiId, LAMBDAARN, APIREGION) 227 | // } catch (e) { 228 | // console.log(e) 229 | // err = e 230 | // } 231 | 232 | // expect(err).not.toBeDefined() 233 | // // 234 | // }, 15 * 1000) 235 | 236 | // // TODO test for when authorizer does not exist yet 237 | // /// //////////////////// 238 | // }) 239 | 240 | // test('throws on authorizerArn not being valid ARN format', async () => { 241 | // const { setDefaultRouteAuthorizer } = require('./index') 242 | 243 | // const invalidArn = 'INVALID_AMAZON_ARN_FORMAT' 244 | // const apiId = 'vca3i8138h' // first-http-api in my API Gateway 245 | 246 | // let err 247 | // try { 248 | // await setDefaultRouteAuthorizer(apiId, invalidArn, APIREGION) 249 | // } catch (e) { 250 | // err = e 251 | // } 252 | 253 | // expect(err).toBeDefined() 254 | // }, 15 * 1000) 255 | // }) 256 | // }) 257 | -------------------------------------------------------------------------------- /authorizer-gen/utils.js: -------------------------------------------------------------------------------- 1 | // const uuidv4 = require('uuid').v4 2 | 3 | // /** 4 | // * @description Generates a '''random''' bearer token TODO 5 | // * @returns {string} '''Random''' bearer token 6 | // */ 7 | // function generateRandomBearerToken() { 8 | // const token = uuidv4() 9 | // .replace(/-/g, '') 10 | // return token 11 | // } 12 | 13 | // /** 14 | // * @returns {void} 15 | // * @throws if "bearerToken" does not fix requirements 16 | // */ 17 | // function ensureBearerTokenSecure(bearerToken) { 18 | // // messages mostly for us 19 | // if (typeof bearerToken !== 'string') throw new Error(`Bearer token must be a string but is ${typeof bearerToken}`) 20 | // if (bearerToken.trim().length < 10) throw new Error('Bearer token, trimmed, must be equal longer than 10') 21 | // if (/^[a-zA-Z0-9]+$/.test(bearerToken) === false) throw new Error('Bearer token must fit regex /^[a-zA-Z0-9]+$/ (alphanumeric)') 22 | // } 23 | 24 | // module.exports = { 25 | // generateRandomBearerToken, 26 | // ensureBearerTokenSecure, 27 | // } 28 | -------------------------------------------------------------------------------- /authorizer-gen/utils.oldtest.js: -------------------------------------------------------------------------------- 1 | // /* eslint-disable global-require */ 2 | // describe('authorizer-gen', () => { 3 | // describe('utils', () => { 4 | // describe('generateRandomBearerToken', () => { 5 | // test('is between 0 and 50 characters', () => { 6 | // const { generateRandomBearerToken } = require('./utils') 7 | 8 | // const output = generateRandomBearerToken() 9 | 10 | // expect(output.length).toBeDefined() 11 | // expect(output.length <= 50).toEqual(true) 12 | // expect(output.length > 0).toEqual(true) 13 | // }) 14 | 15 | // test('is alphanumeric', () => { 16 | // const { generateRandomBearerToken } = require('./utils') 17 | 18 | // const output = generateRandomBearerToken() 19 | // const regex = /^[a-zA-Z0-9]+$/ 20 | // expect(regex.test(output)).toEqual(true) 21 | // }) 22 | // }) 23 | // }) 24 | // }) 25 | -------------------------------------------------------------------------------- /bundler/amazon/index.js: -------------------------------------------------------------------------------- 1 | const { _bundle } = require('../utils') 2 | /** 3 | * @description Bundles a given .js files for Amazon with its dependencies using webpack. 4 | * @param {string} inpath Path to entry .js file 5 | * @returns {Promise} The bundled code 6 | */ 7 | async function bundleAmazon(inpath) { 8 | const externals = { 9 | 'aws-sdk': 'aws-sdk', 10 | } 11 | const res = await _bundle(inpath, externals) 12 | return res 13 | } 14 | 15 | module.exports = { 16 | bundleAmazon, 17 | } 18 | -------------------------------------------------------------------------------- /bundler/google/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { _bundle } = require('../utils') 3 | /** 4 | * @description Bundles a given .js files for Google using Webpack. IMPORTANT: excludes any npm packages. 5 | * @param {string} inpath Path to entry .js file 6 | * @returns {Promise} The bundled code 7 | */ 8 | async function bundleGoogle(inpath) { 9 | // Exclude any npm packages (if present) 10 | // From https://github.com/webpack/webpack/issues/603#issuecomment-180509359 11 | const externals = fs.existsSync('node_modules') && fs.readdirSync('node_modules') 12 | 13 | // TODO check if @google is included on Google? 14 | const res = await _bundle(inpath, externals) 15 | return res 16 | } 17 | 18 | module.exports = { 19 | bundleGoogle, 20 | } 21 | -------------------------------------------------------------------------------- /bundler/utils.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const fsp = require('fs').promises 4 | const os = require('os') 5 | const { log } = require('../printers/index') 6 | 7 | /** 8 | * @description Bundles a given .js files with its dependencies using webpack. 9 | * Does not include dependencies that are given in "externals". 10 | * @param {string} inpath Path to entry .js file 11 | * @param {*} externals Webpack 'externals' field of package names we don't need to bundle. 12 | * For example { 'aws-sdk': 'aws-sdk' } to skip 'aws-sdk' 13 | * @returns {Promise} The bundled code 14 | */ 15 | async function _bundle(inpath, externals) { 16 | // create out dir (silly webpack) 17 | const outdir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bundle-')) 18 | const outpath = path.join(outdir, 'bundle.js') 19 | 20 | // console.log(`bundling to ${outpath}`) 21 | return new Promise((resolve, reject) => { 22 | webpack( 23 | { 24 | mode: 'development', 25 | entry: inpath, 26 | target: 'node', 27 | output: { 28 | path: outdir, 29 | filename: 'bundle.js', 30 | // so amazon sees it 31 | libraryTarget: 'commonjs', 32 | // Make webpack perform identical as node 33 | // See https://github.com/node-fetch/node-fetch/issues/450#issuecomment-494475397 34 | // extensions: ['.js'], 35 | // mainFields: ['main'], 36 | 37 | // Fixes "global" 38 | // See https://stackoverflow.com/a/64639975 39 | // globalObject: 'this', 40 | }, 41 | // aws-sdk is already provided in lambda 42 | externals: externals, 43 | }, 44 | (err, stats) => { 45 | if (err || stats.hasErrors() || (stats.compilation.errors.length > 0)) { 46 | // always show bundling error it's useful 47 | log(`Bundling ${inpath} did not work: `) 48 | log(stats.compilation.errors) 49 | reject(err, stats) 50 | } else { 51 | // return the bundle code 52 | fsp.readFile(outpath, { encoding: 'utf8' }) 53 | .then((code) => resolve(code)) 54 | // TODO clean up file 55 | // TODO do in-memory 56 | } 57 | }, 58 | ) 59 | }) 60 | } 61 | 62 | module.exports = { 63 | _bundle, 64 | } 65 | -------------------------------------------------------------------------------- /bundler/utils.test.js: -------------------------------------------------------------------------------- 1 | const fsp = require('fs').promises 2 | const os = require('os') 3 | const path = require('path') 4 | const uuidv4 = require('uuid').v4 5 | const { _bundle } = require('./utils') 6 | 7 | describe('bundler', () => { 8 | test('does not throw on empty js file & returns string', async () => { 9 | const tmpd = await fsp.mkdtemp(path.join(os.tmpdir(), 'bundle-')) 10 | const inpath = path.join(tmpd, 'index.js') 11 | const filecontents = ' ' 12 | await fsp.writeFile(inpath, filecontents) 13 | 14 | let err; 15 | let res 16 | try { 17 | res = await _bundle(inpath) 18 | } catch (e) { 19 | console.log(e) 20 | err = e 21 | } 22 | 23 | expect(err).not.toBeDefined() 24 | expect(typeof res === 'string').toBe(true) 25 | }) 26 | 27 | test('does not throw on js file & returns string', async () => { 28 | const tmpd = await fsp.mkdtemp(path.join(os.tmpdir(), 'bundle-')) 29 | const code = 'module.exports.inc = (x) => x + 1' 30 | const inpath = path.join(tmpd, 'index.js') 31 | 32 | await fsp.writeFile(inpath, code) 33 | 34 | let err; 35 | let res 36 | try { 37 | res = await _bundle(inpath) 38 | } catch (e) { 39 | console.log(e) 40 | err = e 41 | } 42 | 43 | expect(err).not.toBeDefined() 44 | expect(typeof res === 'string').toBe(true) 45 | }) 46 | 47 | test('throws on invalid input path', async () => { 48 | const code = 'module.exports.inc = (x) => x + 1' 49 | const invalidinpath = path.join(os.tmpdir(), `surely-this-path-does-not-exist-${uuidv4()}`) 50 | 51 | let err; 52 | let res 53 | try { 54 | res = await _bundle(invalidinpath) 55 | console.log(res) 56 | } catch (e) { 57 | err = e 58 | } 59 | 60 | expect(err).toBeDefined() 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require('path') 3 | const fs = require('fs') 4 | const semver = require('semver') 5 | const { init, initDumb } = require('./initer/index') 6 | const { getParsedHyperformJson } = require('./parser/index') 7 | const { log } = require('./printers/index') 8 | const { maybeShowSurvey, answerSurvey } = require('./surveyor/index') 9 | const packagejson = require('./package.json') 10 | 11 | // Ingest CLI arguments 12 | // DEV NOTE: Keep it brief and synchronious 13 | 14 | const args = process.argv.slice(2) 15 | 16 | // Check node version 17 | const version = packagejson.engines.node 18 | if (semver.satisfies(process.version, version) !== true) { 19 | console.log(`Hyperform needs node ${version} or newer, but version is ${process.version}.`); 20 | process.exit(1); 21 | } 22 | 23 | if ( 24 | (/deploy/.test(args[0]) === false) 25 | || ((args.length === 1)) 26 | || (args.length === 2) 27 | || (args.length === 3 && args[2] !== '--amazon' && args[2] !== '--google') 28 | || (args.length === 4 && ([args[2], args[3]].includes('--url') === false)) 29 | || args.length >= 5) { 30 | log(`Usage: 31 | $ hf deploy ./some/file.js --amazon # Deploy exports to AWS Lambda 32 | $ hf deploy ./some/file.js --google # Deploy exports to Google Cloud Functions 33 | `) 34 | process.exit(1) 35 | } 36 | 37 | // $ hf MODE FPATH [--url] 38 | // const mode = args[0] 39 | const fpath = args[1] 40 | const isPublic = (args.length === 4 41 | ? ([args[2], args[3]].includes('--url')) 42 | : false) 43 | 44 | const currdir = process.cwd() 45 | 46 | let platform 47 | if (args[2] === '--amazon') platform = 'amazon' 48 | if (args[2] === '--google') platform = 'google' 49 | 50 | // // Mode is init 51 | // if (mode === 'init') { 52 | // initDumb(currdir) 53 | // process.exit() 54 | // } 55 | 56 | // Mode is answer survey 57 | // if (mode === 'answer') { 58 | // const answer = args.slice(1) // words after $ hf answer 59 | // // Send anonymous answer (words and date recorded) 60 | // answerSurvey(answer) 61 | // .then(process.exit()) 62 | // } 63 | 64 | // Mode is deploy 65 | 66 | // try to read hyperform.json 67 | const hyperformJsonExists = fs.existsSync(path.join(currdir, 'hyperform.json')) 68 | if (hyperformJsonExists === false) { 69 | if (platform === 'amazon') { 70 | log(`No hyperform.json found in current directory. Create it with these fields: 71 | 72 | { 73 | "amazon": { 74 | "aws_access_key_id": "...", 75 | "aws_secret_access_key": "...", 76 | "aws_region": "..." 77 | } 78 | } 79 | 80 | `) 81 | } 82 | 83 | if (platform === 'google') { 84 | log(`No hyperform.json found in current directory. Create it with these fields: 85 | 86 | { 87 | "google": { 88 | "gc_project": "...", 89 | "gc_region": "...", 90 | } 91 | } 92 | 93 | `) 94 | } 95 | process.exit(1) 96 | } 97 | // parse and validate hyperform.json 98 | const parsedHyperformJson = getParsedHyperformJson(currdir, platform) 99 | 100 | // Dev Note: Do this as early as possible 101 | 102 | // Load AWS Credentials from hyperform.json into process.env 103 | // These are identical with variables that Amazon CLI uses, so they may be set 104 | // However, that is fine, hyperform.json should still take precedence 105 | if (parsedHyperformJson.amazon != null) { 106 | process.env.AWS_ACCESS_KEY_ID = parsedHyperformJson.amazon.aws_access_key_id, 107 | process.env.AWS_SECRET_ACCESS_KEY = parsedHyperformJson.amazon.aws_secret_access_key, 108 | process.env.AWS_REGION = parsedHyperformJson.amazon.aws_region 109 | // may, may not be defined. 110 | process.env.AWS_SESSION_TOKEN = parsedHyperformJson.amazon.aws_session_token 111 | } 112 | 113 | // Load GC Credentials from hyperform.json into process.env 114 | // These are different from what Google usually occupies (GCLOUD_...) 115 | if (parsedHyperformJson.google != null) { 116 | process.env.GC_PROJECT = parsedHyperformJson.google.gc_project 117 | process.env.GC_REGION = parsedHyperformJson.google.gc_region 118 | } 119 | 120 | // Top-level error boundary 121 | try { 122 | // Main 123 | // Do not import earlier, it needs to absorb process.env set above 124 | // TODO: make less sloppy 125 | const { main } = require('./index') 126 | main(currdir, fpath, platform, parsedHyperformJson, isPublic) 127 | // show anonymous survey question with 1/30 probability 128 | // .then(() => maybeShowSurvey()) 129 | } catch (e) { 130 | log(e) 131 | process.exit(1) 132 | } 133 | -------------------------------------------------------------------------------- /copier/index.js: -------------------------------------------------------------------------------- 1 | const { ncp } = require('ncp') 2 | const path = require('path') 3 | const os = require('os') 4 | const fsp = require('fs').promises 5 | /** 6 | * Creates a copy of a directory in /tmp. 7 | * @param {string} dir 8 | * * @param {string[]} except names of directories or files that will not be included 9 | * (usually ["node_modules", ".git", ".github"]) Uses substring check. 10 | * @returns {string} outpath 11 | */ 12 | async function createCopy(dir, except) { 13 | const outdir = await fsp.mkdtemp(path.join(os.tmpdir(), 'copy-')) 14 | 15 | // See https://www.npmjs.com/package/ncp 16 | 17 | // concurrency limit 18 | ncp.limit = 16 19 | 20 | // Function that is called on every file / dir to determine if it'll be included in the zip 21 | const filterFunc = (p) => { 22 | for (let i = 0; i < except.length; i += 1) { 23 | if (p.includes(except[i])) { 24 | console.log(`Excluding ${p}`) 25 | return false 26 | } 27 | } 28 | return true 29 | } 30 | 31 | const res = await new Promise((resolve, rej) => { 32 | ncp( 33 | dir, 34 | outdir, 35 | { 36 | filter: filterFunc, 37 | }, 38 | (err) => { 39 | if (err) { 40 | rej(err) 41 | } else { 42 | resolve(outdir) 43 | } 44 | }, 45 | ); 46 | }) 47 | 48 | return res 49 | } 50 | 51 | module.exports = { 52 | createCopy, 53 | } 54 | -------------------------------------------------------------------------------- /deployer/amazon/index.js: -------------------------------------------------------------------------------- 1 | const { 2 | createLambda, 3 | deleteLambda, 4 | updateLambdaCode, 5 | createLambdaRole, 6 | isExistsAmazon, 7 | } = require('./utils') 8 | const { logdev } = require('../../printers/index') 9 | 10 | /** 11 | * @description If Lambda "options.name" does not exist yet in "options.region", 12 | * it deploys a new Lambda with given code ("pathToZip") and "options". 13 | * If Lambda exists, it just updates its code with "pathToZip", 14 | * and ignores all options except "options.name" and "options.region" 15 | * @param {*} pathToZip Path to the zipped Lambda code 16 | * @param {{ 17 | * name: string, 18 | * region: string, 19 | * ram?: number, 20 | * timeout?: number, 21 | * handler?: string 22 | * }} options 23 | * @returns {Promise} The Lambda ARN 24 | */ 25 | async function deployAmazon(pathToZip, options) { 26 | if (!options.name || !options.region) { 27 | throw new Error(`name and region must be specified, but are ${options.name}, ${options.region}`) // HF programmer mistake 28 | } 29 | 30 | const existsOptions = { 31 | name: options.name, 32 | region: options.region, 33 | } 34 | // check if lambda exists 35 | const exists = await isExistsAmazon(existsOptions) 36 | 37 | logdev(`amazon isexists ${options.name} : ${exists}`) 38 | // if not, create new role 39 | const roleName = `hf-${options.name}` 40 | const roleArn = await createLambdaRole(roleName) 41 | 42 | /* eslint-disable key-spacing */ 43 | const fulloptions = { 44 | name: options.name, 45 | region: options.region, 46 | role: roleArn, 47 | runtime: 'nodejs12.x', 48 | timeout: options.timeout || 60, // also prevents 0 49 | ram: options.ram || 128, 50 | handler: options.handler || `index.${options.name}`, 51 | } 52 | /* eslint-enable key-spacing */ 53 | 54 | // anonymous function that when run, creates or updates Lambda 55 | let upload 56 | if (exists === true) { 57 | upload = async () => updateLambdaCode(pathToZip, fulloptions) 58 | } else { 59 | upload = async () => createLambda(pathToZip, fulloptions) 60 | } 61 | 62 | // Helper 63 | const sleep = async (millis) => new Promise((resolve) => { 64 | setTimeout(() => { 65 | resolve() 66 | }, millis); 67 | }) 68 | 69 | // Retry loop (4 times). Usually it fails once or twice 70 | // if role is newly created because it's too fresh 71 | // See: https://stackoverflow.com/a/37503076 72 | 73 | let arn 74 | 75 | for (let i = 0; i < 4; i += 1) { 76 | try { 77 | logdev('trying to upload to amazon') 78 | arn = await upload() 79 | logdev('success uploading to amazon') 80 | break // we're done 81 | } catch (e) { 82 | // TODO write test that enters here, reliably 83 | if (e.code === 'InvalidParameterValueException') { 84 | logdev('amazon deploy threw InvalidParameterValueException (role not ready yet). Retrying in 3 seconds...') 85 | await sleep(3000) // wait 3 seconds 86 | continue 87 | } else { 88 | logdev(`Amazon upload errorred: ${e}`) 89 | logdev(JSON.stringify(e, null, 2)) 90 | throw e; 91 | } 92 | } 93 | } 94 | 95 | console.timeEnd(`Amazon-deploy-${options.name}`) 96 | 97 | return arn 98 | } 99 | 100 | /** 101 | * @description Deletes a Lambda function in a given region. 102 | * @param {string} name Name, ARN or partial ARN of the function 103 | * @param {string} region Region of the function 104 | * @throws ResourceNotFoundException, among others 105 | */ 106 | async function deleteAmazon(name, region) { 107 | await deleteLambda(name, region) 108 | } 109 | 110 | module.exports = { 111 | deployAmazon, 112 | deleteAmazon, 113 | } 114 | -------------------------------------------------------------------------------- /deployer/amazon/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | const LAMBDANAME = 'jest-reserved-returna1' 4 | const LAMBDAREGION = 'us-east-2' 5 | 6 | // After all tests, delete the Lambda 7 | afterAll(async () => { 8 | const { deleteAmazon } = require('./index') 9 | try { 10 | await deleteAmazon(LAMBDANAME, LAMBDAREGION) 11 | } catch (e) { 12 | /* tests themselves already deleted the Lambda */ 13 | } 14 | }) 15 | 16 | // Helpers 17 | const arnRegex = /arn:(aws[a-zA-Z-]*)?:lambda:[a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\d{1}:\d{12}:function:[a-zA-Z0-9-_]+(:(\$LATEST|[a-zA-Z0-9-_]+))?/ 18 | const isArn = (str) => (typeof str === 'string') && (arnRegex.test(str) === true) 19 | 20 | describe('deployer', () => { 21 | describe('amazon', () => { 22 | describe('deployAmazon', () => { 23 | test('completes if Lambda does not exist, and returns ARN', async () => { 24 | const { deleteAmazon, deployAmazon } = require('./index.js') 25 | const { zip } = require('../../zipper/index') 26 | 27 | /// ////////////////////////////////////////////// 28 | // Setup: delete function if it exists already 29 | 30 | try { 31 | await deleteAmazon(LAMBDANAME, LAMBDAREGION) 32 | // deleted function 33 | } catch (e) { 34 | if (e.code === 'ResourceNotFoundException') { 35 | // does not exist in the first place, nice 36 | } else { 37 | throw e 38 | } 39 | } 40 | 41 | /// ////////////////////////////////////////////// 42 | // Setup: create code zip 43 | 44 | const code = `module.exports = { ${LAMBDANAME}: () => ({a: 1}) }` 45 | const zipPath = await zip({ 46 | 'index.js': code, 47 | }) 48 | 49 | /// //////////////////////////////////////////////////// 50 | 51 | const options = { 52 | name: LAMBDANAME, 53 | region: LAMBDAREGION, 54 | } 55 | 56 | let err 57 | let lambdaArn 58 | try { 59 | lambdaArn = await deployAmazon(zipPath, options) 60 | } catch (e) { 61 | console.log(e) 62 | err = e 63 | } 64 | 65 | // it completed 66 | expect(err).not.toBeDefined() 67 | 68 | // it's an ARN 69 | expect(isArn(lambdaArn)).toBe(true) 70 | 71 | // TODO 72 | }, 15 * 1000) 73 | 74 | test('completes if Lambda already exists, and returns ARN', async () => { 75 | const { deployAmazon } = require('./index') 76 | const { zip } = require('../../zipper/index') 77 | 78 | /// ////////////////////////////////////////////// 79 | // Setup: create code zip 80 | 81 | const code = `module.exports = { ${LAMBDANAME}: () => ({a: 1}) }` 82 | 83 | const zipPath = await zip({ 84 | 'index.js': code, 85 | }) 86 | 87 | /// ////////////////////////////////////////////// 88 | // Setup: ensure function exists 89 | 90 | const options = { 91 | name: LAMBDANAME, 92 | region: LAMBDAREGION, 93 | } 94 | try { 95 | await deployAmazon(zipPath, options) 96 | } catch (e) { 97 | if (e.code === 'ResourceConflictException') { 98 | /* exists already anyway, cool */ 99 | } else { 100 | throw e 101 | } 102 | } 103 | 104 | /// //////////////////////////////////////////////////// 105 | // Actual test 106 | 107 | let err 108 | let lambdaArn 109 | try { 110 | lambdaArn = await deployAmazon(zipPath, options) 111 | } catch (e) { 112 | console.log(e) 113 | err = e 114 | } 115 | 116 | // it completed 117 | expect(err).not.toBeDefined() 118 | 119 | // it's an ARN 120 | expect(isArn(lambdaArn)).toBe(true) 121 | }, 30 * 1000) 122 | }) 123 | 124 | // NOTE if we're short on API calls we can sacrifice this: 125 | // describe('isExistsAmazon', () => { 126 | // test('returns true on existing Lambda in name, region', async () => { 127 | // const { isExistsAmazon } = require('./utils') 128 | // const { deployAmazon } = require('./index') 129 | // const { zip } = require('../../zipper/index') 130 | 131 | // /// ////////////////////////////////////////////// 132 | // // Setup: create code zip 133 | 134 | // const code = `module.exports = { ${LAMBDANAME}: () => ({a: 1}) }` 135 | 136 | // const zipPath = await zip(code) 137 | 138 | // /// ////////////////////////////////////////////// 139 | // // Setup: ensure function exists 140 | 141 | // const deployOptions = { 142 | // name: LAMBDANAME, 143 | // region: LAMBDAREGION, 144 | // } 145 | // try { 146 | // await deployAmazon(zipPath, deployOptions) 147 | // } catch (e) { 148 | // if (e.code === 'ResourceConflictException') { 149 | // /* exists already anyway, cool */ 150 | // } else { 151 | // throw e 152 | // } 153 | // } 154 | 155 | // let err 156 | // let res 157 | // try { 158 | // res = await isExistsAmazon({ name: LAMBDANAME, region: LAMBDAREGION }) 159 | // } catch (e) { 160 | // err = e 161 | // } 162 | 163 | // expect(err).not.toBeDefined() 164 | // expect(typeof res).toBe('boolean') 165 | // expect(res).toEqual(true) 166 | // }) 167 | 168 | // test('returns false on non-existing Lambda in name, region', async () => { 169 | // const { isExistsAmazon } = require('./utils') 170 | // const uuidv4 = require('uuid').v4 171 | 172 | // const options = { 173 | // name: `some-invalid-lambda-name-${uuidv4()}`, 174 | // region: LAMBDAREGION, // or whatever 175 | // } 176 | 177 | // let err 178 | // let res 179 | // try { 180 | // res = await isExistsAmazon(options) 181 | // } catch (e) { 182 | // err = e 183 | // } 184 | 185 | // expect(err).not.toBeDefined() 186 | // expect(typeof res).toBe('boolean') 187 | // expect(res).toEqual(false) 188 | // }) 189 | // }) 190 | 191 | // TODO more tests for the other methods 192 | }) 193 | }) 194 | -------------------------------------------------------------------------------- /deployer/amazon/utils.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | const exec = util.promisify(require('child_process').exec); 3 | const AWS = require('aws-sdk') 4 | const fsp = require('fs').promises 5 | const { logdev } = require('../../printers/index') 6 | 7 | const conf = { 8 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 9 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 10 | region: process.env.AWS_REGION, 11 | // may, may not be defined 12 | // sessionToken: process.env.AWS_SESSION_TOKEN || undefined, 13 | } 14 | 15 | if (process.env.AWS_SESSION_TOKEN != null && process.env.AWS_SESSION_TOKEN !== 'undefined') { 16 | conf.sessionToken = process.env.AWS_SESSION_TOKEN 17 | } 18 | 19 | AWS.config.update(conf) 20 | 21 | /** 22 | * @description Creates a new Lambda with given function code and options 23 | * @param {string} pathToZip Path to the zipped Lambda code 24 | * @param {{ 25 | * name: string, 26 | * region: string, 27 | * role: string, 28 | * runtime: string, 29 | * timeout: number, 30 | * ram: number, 31 | * handler: string 32 | * }} options 33 | * @returns {Promise} The ARN of the Lambda 34 | * @throws If Lambda exists or creation did not succeed 35 | */ 36 | async function createLambda(pathToZip, options) { 37 | const lambda = new AWS.Lambda({ 38 | region: options.region, 39 | apiVersion: '2015-03-31', 40 | }) 41 | 42 | const zipContents = await fsp.readFile(pathToZip) 43 | const params = { 44 | Code: { 45 | ZipFile: zipContents, 46 | }, 47 | FunctionName: options.name, 48 | Timeout: options.timeout, 49 | Role: options.role, 50 | MemorySize: options.ram, 51 | Handler: options.handler, 52 | Runtime: options.runtime, 53 | } 54 | 55 | const res = await lambda.createFunction(params).promise() 56 | const arn = res.FunctionArn 57 | 58 | return arn 59 | } 60 | 61 | /** 62 | * @description Deletes a Lambda function in a given region. 63 | * @param {string} name Name, ARN or partial ARN of the function 64 | * @param {string} region Region of the function 65 | * @throws ResourceNotFoundException, among others 66 | */ 67 | async function deleteLambda(name, region) { 68 | const lambda = new AWS.Lambda({ 69 | region: region, 70 | apiVersion: '2015-03-31', 71 | }) 72 | 73 | const params = { 74 | FunctionName: name, 75 | } 76 | 77 | await lambda.deleteFunction(params).promise() 78 | } 79 | 80 | /** 81 | * @description Updates a Lambda's function code with a given .zip file 82 | * @param {string} pathToZip Path to the zipped Lambda code 83 | * @param {{ 84 | * name: string, 85 | * region: string 86 | * }} options 87 | * @throws If Lambda does not exist or update did not succeed 88 | * @returns {Promise} The ARN of the Lambda 89 | */ 90 | async function updateLambdaCode(pathToZip, options) { 91 | const lambda = new AWS.Lambda({ 92 | region: options.region, 93 | apiVersion: '2015-03-31', 94 | }) 95 | 96 | const zipContents = await fsp.readFile(pathToZip) 97 | 98 | const params = { 99 | FunctionName: options.name, 100 | ZipFile: zipContents, 101 | } 102 | 103 | const res = await lambda.updateFunctionCode(params).promise() 104 | const arn = res.FunctionArn 105 | 106 | return arn 107 | } 108 | 109 | /** 110 | * @description Creates a new role, and attaches a basic Lambda policy 111 | * (AWSLambdaBasicExecutionRole) to it. If role with that name 112 | * exists already, it just attaches the policy to it 113 | * @param {string} roleName Unique name to be given to the role 114 | * @returns {Promise} ARN of the created or updated role 115 | * @see https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/using-lambda-iam-role-setup.html 116 | */ 117 | async function createLambdaRole(roleName) { 118 | const iam = new AWS.IAM() 119 | 120 | const lambdaPolicy = { 121 | Version: '2012-10-17', 122 | Statement: [ 123 | { 124 | Effect: 'Allow', 125 | Principal: { 126 | Service: 'lambda.amazonaws.com', 127 | }, 128 | Action: 'sts:AssumeRole', 129 | }, 130 | ], 131 | } 132 | 133 | const createParams = { 134 | AssumeRolePolicyDocument: JSON.stringify(lambdaPolicy), 135 | RoleName: roleName, 136 | } 137 | 138 | let roleArn 139 | try { 140 | const createRes = await iam.createRole(createParams).promise() 141 | // Role did not exist yet 142 | roleArn = createRes.Role.Arn 143 | } catch (e) { 144 | if (e.code === 'EntityAlreadyExists') { 145 | // Role with that name already exists 146 | // Use that role, proceed normally 147 | const getParams = { 148 | RoleName: roleName, 149 | } 150 | logdev(`role with name ${roleName} already exists. getting its arn`) 151 | const getRes = await iam.getRole(getParams).promise() 152 | roleArn = getRes.Role.Arn 153 | } else { 154 | // some other error 155 | throw e 156 | } 157 | } 158 | 159 | // Attach a basic Lambda policy to the role (allows writing to cloudwatch logs etc) 160 | // Equivalent to in Lambda console, selecting 'Create new role with basic permissions' 161 | const policyParams = { 162 | PolicyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', 163 | RoleName: roleName, 164 | } 165 | await iam.attachRolePolicy(policyParams).promise() 166 | logdev(`successfully attached AWSLambdaBasicExecutionRole to ${roleName}`) 167 | 168 | return roleArn 169 | } 170 | 171 | /** 172 | * @description Checks whether a Lambda exists in a given region 173 | * @param {{ 174 | * name: string, 175 | * region: string 176 | * }} options 177 | * @returns {Promise} 178 | */ 179 | async function isExistsAmazon(options) { 180 | const lambda = new AWS.Lambda({ 181 | region: options.region, 182 | apiVersion: '2015-03-31', 183 | }) 184 | 185 | const params = { 186 | FunctionName: options.name, 187 | } 188 | 189 | try { 190 | await lambda.getFunction(params).promise() 191 | return true 192 | } catch (e) { 193 | if (e.code === 'ResourceNotFoundException') { 194 | return false 195 | } else { 196 | // some other error 197 | throw e 198 | } 199 | } 200 | } 201 | 202 | // TODO 203 | // /** 204 | // * @throws If Lambda does not exist 205 | // */ 206 | // async function updateLambdaConfiguration() { 207 | 208 | // } 209 | 210 | module.exports = { 211 | createLambda, 212 | deleteLambda, 213 | updateLambdaCode, 214 | createLambdaRole, 215 | isExistsAmazon, 216 | } 217 | -------------------------------------------------------------------------------- /deployer/google/index.js: -------------------------------------------------------------------------------- 1 | const fsp = require('fs').promises 2 | const { CloudFunctionsServiceClient } = require('@google-cloud/functions'); 3 | const fetch = require('node-fetch') 4 | const { logdev } = require('../../printers/index') 5 | 6 | let gcOptions 7 | if (process.env.GC_PROJECT) { 8 | gcOptions = { 9 | projectId: process.env.GC_PROJECT, 10 | } 11 | } 12 | // Don't consult hyperform.json yet for Google credentials 13 | 14 | // if (process.env.GC_CLIENT_EMAIL && process.env.GC_PRIVATE_KEY && process.env.GC_PROJECT) { 15 | // gcOptions = { 16 | // credentials: { 17 | // client_email: process.env.GC_CLIENT_EMAIL, 18 | // private_key: process.env.GC_PRIVATE_KEY, 19 | // }, 20 | // projectId: process.env.GC_PROJECT, 21 | // } 22 | // } 23 | 24 | const client = new CloudFunctionsServiceClient(gcOptions) 25 | 26 | /** 27 | * @description Checks whether a GCF 28 | * exists in a given project & region 29 | * @param {{ 30 | * name: string, 31 | * project:string 32 | * region: string, 33 | * }} options 34 | * @returns {Promise} 35 | */ 36 | async function isExistsGoogle(options) { 37 | const getParams = { 38 | name: `projects/${options.project}/locations/${options.region}/functions/${options.name}`, 39 | } 40 | 41 | try { 42 | const res = await client.getFunction(getParams) 43 | if (res.length > 0 && res.filter((el) => el).length > 0) { 44 | return true 45 | } else { 46 | return false 47 | } 48 | } catch (e) { 49 | return false 50 | } 51 | } 52 | 53 | /** 54 | * @description Uploads a given file (usually code .zips) to a temporary 55 | * Google storage and returns 56 | * its so-called signed URL. 57 | * This URL can then be used, for example 58 | * as sourceUploadUrl for creating and updating Cloud Functions. 59 | * @param {string} pathToFile 60 | * @param {{ 61 | * project: string, 62 | * region: string 63 | * }} options 64 | * @returns {Promise} The signed upload URL 65 | * @see https://cloud.google.com/storage/docs/access-control/signed-urls Google Documentation 66 | */ 67 | async function uploadGoogle(pathToFile, options) { 68 | const generateUploadUrlOptions = { 69 | parent: `projects/${options.project}/locations/${options.region}`, 70 | } 71 | 72 | const signedUploadUrl = (await client.generateUploadUrl(generateUploadUrlOptions))[0].uploadUrl 73 | 74 | // Upload zip 75 | // TODO use createReadStream instead 76 | const zipBuf = await fsp.readFile(pathToFile) 77 | await fetch(signedUploadUrl, { 78 | method: 'PUT', 79 | headers: { 80 | 'content-type': 'application/zip', 81 | 'x-goog-content-length-range': '0,104857600', 82 | }, 83 | body: zipBuf, 84 | }) 85 | logdev('uploaded zip to google signed url') 86 | 87 | return signedUploadUrl 88 | } 89 | 90 | /** 91 | * @description Updates an existing GCF "options.name" in "options.project", "options.region" 92 | * with given uploaded code .zip. 93 | * And, in theory, arbitrary options too (timeout, availableMemoryMb), 94 | * but currently not needed but could easily be added. 95 | * Returns immediately, but Google updates for 1-2 minutes more 96 | * @param {string} signedUploadUrl Signed upload URL where .zip has been uploaded to already. 97 | * Output of "uploadGoogle". 98 | * @param {{ 99 | * name: string, 100 | * project: string, 101 | * region: string, 102 | * runtime: string 103 | * }} options 104 | */ 105 | async function _updateGoogle(signedUploadUrl, options) { 106 | const updateOptions = { 107 | function: { 108 | name: `projects/${options.project}/locations/${options.region}/functions/${options.name}`, 109 | httpsTrigger: { 110 | url: `https://${options.region}-${options.project}.cloudfunctions.net/${options.name}`, 111 | }, 112 | runtime: options.runtime, 113 | timeout: { 114 | seconds: 120, 115 | }, 116 | sourceUploadUrl: signedUploadUrl, 117 | }, 118 | // TODO Empty array to not overwrite 'timeout' or 'runtime' 119 | updateMask: null, 120 | // this does not work :( 121 | // updateMask: { 122 | // paths: ['sourceUploadUrl'] 123 | // } 124 | } 125 | const res = await client.updateFunction(updateOptions) 126 | logdev(`google: updated function ${options.name}`) 127 | } 128 | 129 | /** 130 | * @description Creates a new GCF "options.name" in "options.project", "options.region" 131 | * with given uploaded code .zip and options. 132 | * Returns immediately, but Google creates for 1-2 minutes more 133 | * @param {string} signedUploadUrl 134 | * @param {{ 135 | * name: string, 136 | * project: string, 137 | * region: string, 138 | * runtime: string, 139 | * entrypoint?: string, 140 | * }} options 141 | */ 142 | async function _createGoogle(signedUploadUrl, options) { 143 | const createOptions = { 144 | location: `projects/${options.project}/locations/${options.region}`, 145 | function: { 146 | name: `projects/${options.project}/locations/${options.region}/functions/${options.name}`, 147 | httpsTrigger: { 148 | url: `https://${options.region}-${options.project}.cloudfunctions.net/${options.name}`, 149 | }, 150 | entryPoint: options.entrypoint || options.name, 151 | runtime: options.runtime, 152 | sourceUploadUrl: signedUploadUrl, 153 | timeout: { 154 | seconds: 60, // 155 | }, // those are the defaults anyway 156 | availableMemoryMb: 256, 157 | }, 158 | } 159 | const res = await client.createFunction(createOptions) 160 | // TODO wait for operaton to complete (ie setInterval done && !error, promise resolve then) 161 | // TODO in _updateGoogle too 162 | logdev(`google: created function ${options.name}`) 163 | } 164 | 165 | /** 166 | * 167 | * @param {{ 168 | * name: string, 169 | * project: string, 170 | * region: string 171 | * }} options 172 | */ 173 | async function _allowPublicInvokeGoogle(options) { 174 | // TODO GetIam and get etag of current role first 175 | // And then specify that in setIam, to avoid race conditions 176 | // @see "etag" on https://cloud.google.com/functions/docs/reference/rest/v1/Policy 177 | 178 | const setIamPolicyOptions = { 179 | resource: `projects/${options.project}/locations/${options.region}/functions/${options.name}`, 180 | policy: { 181 | // @see https://cloud.google.com/functions/docs/reference/rest/v1/Policy#Binding 182 | bindings: [ 183 | { 184 | role: 'roles/cloudfunctions.invoker', 185 | members: ['allUsers'], 186 | version: 3, 187 | }, 188 | ], 189 | }, 190 | } 191 | 192 | logdev('setting IAM policy') 193 | const res = await client.setIamPolicy(setIamPolicyOptions) 194 | } 195 | 196 | /** 197 | * @description If Google Cloud Function "options.name" 198 | * does not exist yet in "options.project", "options.region", 199 | * it creates a new GCF with given code ("pathToZip") and "options". 200 | * If GCF exists already, it updates its code with "pathToZip". 201 | * If other options are specified, it can update those too (currently only "runtime"). 202 | * Returns IAM-protected URL immediately, but Cloud Function takes another 1-2 minutes to be invokable. 203 | * @param {string} pathToZip 204 | * @param {{ 205 | * name: string, 206 | * project: string, 207 | * region: string, 208 | * runtime: string, 209 | * entrypoint?: string 210 | * }} options 211 | * @returns {Promise} The endpoint URL 212 | * @see https://cloud.google.com/functions/docs/reference/rest/v1/projects.locations.functions#CloudFunction For the underlying Google SDK documentation 213 | */ 214 | async function deployGoogle(pathToZip, options) { 215 | if (!options.name || !options.project || !options.region || !options.runtime) { 216 | throw new Error(`name, project and region and runtime must be defined but are ${options.name}, ${options.project}, ${options.region}, ${options.runtime}`) // HF programmer mistake 217 | } 218 | 219 | const existsOptions = { 220 | name: options.name, 221 | project: options.project, 222 | region: options.region, 223 | } 224 | // Check if GCF exists 225 | const exists = await isExistsGoogle(existsOptions) 226 | 227 | logdev(`google isexists ${options.name}: ${exists}`) 228 | 229 | // Either way, upload the .zip 230 | // @see https://cloud.google.com/storage/docs/access-control/signed-urls 231 | const signedUploadUrl = await uploadGoogle(pathToZip, { 232 | project: options.project, 233 | region: options.region, 234 | }) 235 | 236 | // if GCF does not exist yet, create it 237 | if (exists !== true) { 238 | const createParams = { 239 | ...options, 240 | } 241 | await _createGoogle(signedUploadUrl, createParams) 242 | } else { 243 | // GCF exists, update code and options (currently none) 244 | const updateParams = { 245 | name: options.name, 246 | project: options.project, 247 | region: options.region, 248 | runtime: options.runtime, 249 | } 250 | await _updateGoogle(signedUploadUrl, updateParams) 251 | } 252 | 253 | // Construct endpoint URL (it's deterministic) 254 | const endpointUrl = `https://${options.region}-${options.project}.cloudfunctions.net/${options.name}` 255 | 256 | // Note: GCF likely not ready by the time we return its URL here 257 | return endpointUrl 258 | } 259 | 260 | /** 261 | * @description Allows anyone to call function via its HTTP endpoint. 262 | * Does so by turning IAM checking of Google off. 263 | * Unlike publishAmazon, publishgoogle it does not return an URL, deployGoogle does that already. 264 | * @param {*} name 265 | * @param {*} project 266 | * @param {*} region 267 | */ 268 | async function publishGoogle(name, project, region) { 269 | const allowPublicInvokeOptions = { 270 | name: name, 271 | project: project, 272 | region: region, 273 | } 274 | await _allowPublicInvokeGoogle(allowPublicInvokeOptions) 275 | } 276 | 277 | module.exports = { 278 | deployGoogle, 279 | publishGoogle, 280 | _only_for_testing_isExistsGoogle: isExistsGoogle, 281 | } 282 | -------------------------------------------------------------------------------- /deployer/google/index.test.js: -------------------------------------------------------------------------------- 1 | const GCFREGION = 'us-central1' 2 | const GCFPROJECT = 'firstnodefunc' 3 | const GCFRUNTIME = 'nodejs12' 4 | 5 | describe('deployer', () => { 6 | describe('google', () => { 7 | describe('deployGoogle', () => { 8 | // Google does not reliably complete deploy/delete at returning 9 | // Therefore you can't really setup it properly 10 | // because setup might overlap with the test itself 11 | 12 | test('completes if GCF exists already, and returns an URL', async () => { 13 | const { deployGoogle } = require('./index') 14 | const { zip } = require('../../zipper/index') 15 | 16 | /// ////////////////////////////////////////////// 17 | // Setup: create folder with code 18 | 19 | /// ////////////////////////////////////////////// 20 | // Setup: create code zip 21 | 22 | const name = 'jest_reserved_deployGoogle_A' 23 | const code = `module.exports = { ${name}: () => ({a: 1}) }` 24 | const zipPath = await zip({ 25 | 'index.js': code, 26 | }) 27 | 28 | /// //////////////////////////////////////////////////// 29 | 30 | const options = { 31 | name: name, 32 | project: GCFPROJECT, 33 | region: GCFREGION, 34 | runtime: GCFRUNTIME, 35 | } 36 | 37 | let err 38 | let res 39 | try { 40 | res = await deployGoogle(zipPath, options) 41 | } catch (e) { 42 | console.log(e) 43 | err = e 44 | } 45 | 46 | // it completed 47 | expect(err).not.toBeDefined() 48 | 49 | // it's an URL 50 | const tryUrl = () => new URL(res) 51 | expect(tryUrl).not.toThrow() 52 | // Note: the URL would then take another 1-2 minutes to point to a proper GCF 53 | }, 2 * 60 * 1000) 54 | }) 55 | 56 | describe('isExistsGoogle', () => { 57 | test('true on existing function in project, region', async () => { 58 | const isExistsGoogle = require('./index')._only_for_testing_isExistsGoogle 59 | 60 | const input = { 61 | name: 'endpoint_oho', // TODO make reserved funcs for tests 62 | project: GCFPROJECT, 63 | region: GCFREGION, 64 | } 65 | 66 | let err 67 | let res 68 | try { 69 | res = await isExistsGoogle(input) 70 | } catch (e) { 71 | err = e 72 | } 73 | 74 | expect(err).not.toBeDefined() 75 | expect(res).toEqual(true) 76 | }) 77 | 78 | test('false on non-existing function in project, region', async () => { 79 | const isExistsGoogle = require('./index')._only_for_testing_isExistsGoogle 80 | 81 | const input = { 82 | name: 'SOME_NONEXISTING_FUNCTION_0987654321', // TODO make reserved funcs for tests 83 | project: GCFPROJECT, 84 | region: GCFREGION, 85 | } 86 | 87 | let err 88 | let res 89 | try { 90 | res = await isExistsGoogle(input) 91 | } catch (e) { 92 | err = e 93 | } 94 | 95 | // should still not throw 96 | expect(err).not.toBeDefined() 97 | expect(res).toEqual(false) 98 | }) 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /discoverer/index.js: -------------------------------------------------------------------------------- 1 | const findit = require('findit') 2 | const path = require('path') 3 | const { EOL } = require('os') 4 | const fs = require('fs') 5 | const { log } = require('../printers/index') 6 | 7 | const BLACKLIST = [ 8 | '.git', 9 | 'node_modules', 10 | ] 11 | 12 | /** 13 | * @description Searches "absdir" and its subdirectories for .js files 14 | * @param {string} absdir An absolute path to a directory 15 | * @returns {Promise} Array of absolute file paths to .js files 16 | */ 17 | // TODO do not follow symlinks (or do?) 18 | function getJsFilepaths(absdir) { 19 | return new Promise((resolve, reject) => { 20 | const fnames = [] 21 | const finder = findit(absdir) 22 | 23 | finder.on('directory', (_dir, stat, stop) => { 24 | const base = path.basename(_dir); 25 | if (BLACKLIST.includes(base)) { 26 | stop() 27 | } 28 | }); 29 | 30 | finder.on('file', (file, stat) => { 31 | // only return .js files 32 | if (/.js$/.test(file) === true) { 33 | fnames.push(file) 34 | } 35 | }); 36 | 37 | finder.on('end', () => { 38 | resolve(fnames) 39 | }) 40 | 41 | finder.on('error', (err) => { 42 | reject(err) 43 | }) 44 | }) 45 | } 46 | 47 | /** 48 | * @description Runs a .js file to get its named export keys 49 | * @param {string} filepath Path to .js file 50 | * @returns {string[]} Array of named export keys 51 | */ 52 | function getNamedExportKeys(filepath) { 53 | // hides that code is run but actually runs it lol 54 | // TODOfind a way to get exports without running code 55 | try { 56 | const imp = (() => 57 | // console.log = () => {} 58 | // console.error = () => {} 59 | // console.warn = () => {} 60 | require(filepath) 61 | )() 62 | const namedexpkeys = Object.keys(imp) 63 | return namedexpkeys 64 | } catch (e) { 65 | // if js file isn't parseable, top level code throws, etc 66 | // ignore it 67 | log(`Could not determine named exports of ${filepath}. Try to fix the error and try again: ${EOL} Error: ${e}`) 68 | return [] 69 | } 70 | } 71 | 72 | /** 73 | * Checks if file contents match a regex 74 | * @param {*} fpath 75 | * @param {*} regex 76 | * @returns {boolean} 77 | */ 78 | function isFileContains(fpath, regex) { 79 | const contents = fs.readFileSync(fpath, { encoding: 'utf-8' }) 80 | const res = regex.test(contents) 81 | return res 82 | } 83 | 84 | /** 85 | * @description Scouts "dir" and its subdirectories for .js files named 86 | * exports that match "fnregex" 87 | * @param {string} dir 88 | * @param {Regex} fnregex 89 | * @returns {[ { p: string, exps: string[] } ]} Array of "p" (path to js file) 90 | * and its named exports that match fnregex ("exps") 91 | */ 92 | async function getInfos(dir, fnregex) { 93 | let jsFilePaths = await getJsFilepaths(dir) 94 | // First check - filter out files that don't even contain a string matching fnregex (let alone export it) 95 | jsFilePaths = jsFilePaths 96 | .filter((p) => isFileContains(p, fnregex)) 97 | // Second check - determine exports by running file, and keep those that export sth matching fnregex 98 | const infos = jsFilePaths 99 | .map((p) => ({ 100 | p: p, 101 | exps: getNamedExportKeys(p), 102 | })) 103 | // skip files that don't have named exports 104 | .filter(({ exps }) => exps != null && exps.length > 0) 105 | // skip files that don't have named exports that fit fnregex 106 | .filter(({ exps }) => exps.some((exp) => fnregex.test(exp) === true)) 107 | // filter out exports that don't fit fnregex 108 | .map((el) => ({ ...el, exps: el.exps.filter((exp) => fnregex.test(exp) === true) })) 109 | 110 | return infos 111 | } 112 | 113 | module.exports = { 114 | getInfos, 115 | getNamedExportKeys, 116 | } 117 | -------------------------------------------------------------------------------- /hyperform-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperform-dev/hyperform/24f4e1b742ca67ec3c47ebaa5ccac6a38d9f5085/hyperform-banner.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 3 | const chalk = require('chalk') 4 | const semver = require('semver') 5 | const fs = require('fs') 6 | const fsp = require('fs').promises 7 | const path = require('path') 8 | const { EOL } = require('os') 9 | const { bundleAmazon } = require('./bundler/amazon/index') 10 | const { bundleGoogle } = require('./bundler/google/index') 11 | const { getNamedExportKeys } = require('./discoverer/index') 12 | const { deployAmazon } = require('./deployer/amazon/index') 13 | const { publishAmazon } = require('./publisher/amazon/index') 14 | const { spinnies, log, logdev } = require('./printers/index') 15 | const { zip } = require('./zipper/index') 16 | const { deployGoogle, publishGoogle } = require('./deployer/google/index') 17 | const { transpile } = require('./transpiler/index') 18 | const packagejson = require('./package.json') 19 | const { createCopy } = require('./copier/index') 20 | const { zipDir } = require('./zipper/google/index') 21 | const { kindle } = require('./kindler/index') 22 | const { amazonSchema, googleSchema } = require('./schemas/index') 23 | /** 24 | * 25 | * @param {string} fpath Path to .js file 26 | */ 27 | async function bundleTranspileZipAmazon(fpath) { 28 | // Bundle 29 | let amazonBundledCode 30 | try { 31 | amazonBundledCode = await bundleAmazon(fpath) 32 | } catch (e) { 33 | log(`Errored bundling ${fpath} for Amazon: ${e}`) 34 | return // just skip that file 35 | } 36 | 37 | // Transpile 38 | const amazonTranspiledCode = transpile(amazonBundledCode) 39 | 40 | // Zip 41 | try { 42 | const amazonZipPath = await zip({ 43 | 'index.js': amazonTranspiledCode, 44 | }) 45 | return amazonZipPath 46 | } catch (e) { 47 | // probably underlying issue with the zipping library or OS 48 | // skip that file 49 | log(`Errored zipping ${fpath} for Amazon: ${e}`) 50 | } 51 | } 52 | 53 | // TODO those are basically the same now 54 | // but for later it may be good to have them separate 55 | // in case they start to diverge 56 | 57 | // /** 58 | // * 59 | // * @param {string} fpath Path to .js file 60 | // * @param {string} dir 61 | // */ 62 | // async function bundleTranspileZipGoogle(fpath, dir) { 63 | // // Bundle (omits any npm packages) 64 | // let googleBundledCode 65 | // try { 66 | // googleBundledCode = await bundleGoogle(fpath) 67 | // } catch (e) { 68 | // log(`Errored bundling ${fpath} for Google: ${e}`) 69 | // return // just skip that file 70 | // } 71 | 72 | // // Transpile 73 | // const googleTranspiledCode = transpile(googleBundledCode) 74 | 75 | // // Try to locate a package.json 76 | // // Needed so google installs the npm packages 77 | // const packageJsonPath = path.join(dir, 'package.json') 78 | // let packageJsonContent 79 | // if (fs.existsSync(packageJsonPath)) { 80 | // packageJsonContent = fs.readFileSync(packageJsonPath, { encoding: 'utf-8' }) 81 | // } else { 82 | // // warn 83 | // log(`No package.json found in this directory. 84 | // On Google, therefore no dependencies will be included`) 85 | // } 86 | 87 | // // Zip code and package.json 88 | // try { 89 | // const googleZipPath = await zip({ 90 | // 'index.js': googleTranspiledCode, 91 | // 'package.json': packageJsonContent || undefined, 92 | // }) 93 | // return googleZipPath 94 | // } catch (e) { 95 | // // probably underlying issue with the zipping library or OS 96 | // throw new Error(`Errored zipping ${fpath} for Google: ${e}`) 97 | // } 98 | // } 99 | 100 | async function bundleTranspileZipGoogle(fpath, dir, exps) { 101 | // warn if package.json does not exist 102 | // (Google won't install npm dependencies then) 103 | if (fs.existsSync(path.join(dir, 'package.json')) === false) { 104 | log(`No package.json found in this directory. 105 | On Google, therefore no dependencies will be included`) 106 | } 107 | 108 | // copy whole dir to /tmp so we can tinker with it 109 | const googlecopyDir = await createCopy( 110 | dir, 111 | ['node_modules', '.git', '.github', 'hyperform.json'], 112 | ) 113 | 114 | const indexJsPath = path.join(googlecopyDir, 'index.js') 115 | 116 | let indexJsAppendix = '' 117 | // add import-export appendix 118 | indexJsAppendix = kindle(indexJsAppendix, dir, [ 119 | { 120 | p: fpath, 121 | exps: exps, 122 | }, 123 | ]) 124 | // add platform appendix 125 | indexJsAppendix = transpile(indexJsAppendix) 126 | 127 | // write or append to index.js in our tinker folder 128 | if (fs.existsSync(indexJsPath) === false) { 129 | await fsp.writeFile(indexJsPath, indexJsAppendix, { encoding: 'utf-8' }) 130 | } else { 131 | await fsp.appendFile(indexJsPath, indexJsAppendix, { encoding: 'utf-8' }) 132 | } 133 | 134 | // zip tinker folder 135 | const googleZipPath = await zipDir( 136 | googlecopyDir, 137 | ['node_modules', '.git', '.github', 'hyperform.json'], // superfluous we didnt copy them in the first place 138 | ) 139 | 140 | return googleZipPath 141 | } 142 | 143 | /** 144 | * @description Deploys a given code .zip to AWS Lambda, and gives it a HTTP endpoint via API Gateway 145 | * @param {string} name 146 | * @param {string} region 147 | * @param {string} zipPath 148 | * @param {boolean} isPublic whether to publish 149 | * @returns {string?} If isPublic was true, URL of the endpoint of the Lambda 150 | */ 151 | async function deployPublishAmazon(name, region, zipPath, isPublic) { 152 | const amazonSpinnieName = `amazon-main-${name}` 153 | try { 154 | spinnies.add(amazonSpinnieName, { text: `Deploying ${name} to AWS Lambda` }) 155 | 156 | // Deploy it 157 | const amazonDeployOptions = { 158 | name: name, 159 | region: region, 160 | } 161 | const amazonArn = await deployAmazon(zipPath, amazonDeployOptions) 162 | let amazonUrl 163 | // Publish it if isPpublic 164 | if (isPublic === true) { 165 | amazonUrl = await publishAmazon(amazonArn, region) 166 | } 167 | spinnies.succ(amazonSpinnieName, { text: `🟢 Deployed ${name} to AWS Lambda ${amazonUrl || ''}` }) 168 | 169 | // (return url) 170 | return amazonUrl 171 | } catch (e) { 172 | spinnies.f(amazonSpinnieName, { 173 | text: `Error deploying ${name} to AWS Lambda: ${e.stack}`, 174 | }) 175 | logdev(e, e.stack) 176 | return null 177 | } 178 | } 179 | 180 | // TODO probieren 181 | // TODO tests anpassen 182 | // TODO testen 183 | // TODO tests schreiben, refactoren 184 | 185 | /** 186 | * @description Deploys and publishes a give code .zip to Google Cloud Functions 187 | * @param {string} name 188 | * @param {string} region 189 | * @param {string} project 190 | * @param {string} zipPath 191 | * @param {boolean} isPublic whether to publish 192 | * @returns {string?} If isPublic was true, URL of the Google Cloud Function 193 | */ 194 | async function deployPublishGoogle(name, region, project, zipPath, isPublic) { 195 | const googleSpinnieName = `google-main-${name}` 196 | try { 197 | spinnies.add(googleSpinnieName, { text: `Deploying ${name} to Google Cloud Functions` }) 198 | const googleOptions = { 199 | name: name, 200 | project: project, // process.env.GC_PROJECT, 201 | region: region, // TODO get from parsedhyperfromjson 202 | runtime: 'nodejs12', 203 | } 204 | const googleUrl = await deployGoogle(zipPath, googleOptions) 205 | 206 | if (isPublic === true) { 207 | // enables anyone with the URL to call the function 208 | await publishGoogle(name, project, region) 209 | } 210 | spinnies.succ(googleSpinnieName, { text: `🟢 Deployed ${name} to Google Cloud Functions ${googleUrl || ''}` }) 211 | console.log('Google takes another 1 - 2m for changes to take effect') 212 | 213 | // return url 214 | return googleUrl 215 | } catch (e) { 216 | spinnies.f(googleSpinnieName, { 217 | text: `${chalk.rgb(255, 255, 255).bgWhite(' Google ')} ${name}: ${e.stack}`, 218 | }) 219 | logdev(e, e.stack) 220 | return null 221 | } 222 | } 223 | /** 224 | * @param {string} dir 225 | * @param {Regex} fpath the path to the .js file whose exports should be deployed 226 | * @param {amazon|google} platform 227 | * @param {boolean?} _isPublic 228 | * @param {{amazon: {aws_access_key_id:string, aws_secret_access_key: string, aws_region: string}}} parsedHyperformJson 229 | */ 230 | async function main(dir, fpath, platform, parsedHyperformJson, _isPublic) { 231 | // Check node version (again) 232 | const version = packagejson.engines.node 233 | if (semver.satisfies(process.version, version) !== true) { 234 | console.log(`Hyperform needs node ${version} or newer, but version is ${process.version}.`); 235 | process.exit(1); 236 | } 237 | 238 | // verify parsedHyperformJson (again) 239 | let schema 240 | if (platform === 'amazon') schema = amazonSchema 241 | if (platform === 'google') schema = googleSchema 242 | const { error, value } = schema.validate(parsedHyperformJson) 243 | if (error) { 244 | throw new Error(`${error} ${value}`) 245 | } 246 | 247 | const absfpath = path.resolve(dir, fpath) 248 | 249 | // determine named exports 250 | const exps = getNamedExportKeys(absfpath) 251 | 252 | if (exps.length === 0) { 253 | log(`No named CommonJS exports found in ${absfpath}. ${EOL}Named exports have the form 'module.exports = { ... }' or 'exports.... = ...' `) 254 | return [] // no endpoint URLs created 255 | } 256 | 257 | const isToAmazon = platform === 'amazon' 258 | const isToGoogle = platform === 'google' 259 | 260 | let amazonZipPath 261 | let googleZipPath 262 | 263 | if (isToAmazon === true) { 264 | amazonZipPath = await bundleTranspileZipAmazon(absfpath) 265 | } 266 | 267 | if (isToGoogle === true) { 268 | googleZipPath = await bundleTranspileZipGoogle(absfpath, dir, exps) 269 | } 270 | 271 | /// /////////////////////////////////////////////////// 272 | /// Each export, deploy as function & publish. Obtain URL. 273 | /// /////////////////////////////////////////////////// 274 | const isPublic = _isPublic || false 275 | 276 | let endpoints = await Promise.all( 277 | // For each export 278 | exps.map(async (exp) => { 279 | /// ////////////////////////////////////////////////////////// 280 | /// Deploy to Amazon 281 | /// ////////////////////////////////////////////////////////// 282 | let amazonUrl 283 | if (isToAmazon === true) { 284 | amazonUrl = await deployPublishAmazon( 285 | exp, 286 | parsedHyperformJson.amazon.aws_region, 287 | amazonZipPath, 288 | isPublic, 289 | ) 290 | } 291 | 292 | /// ////////////////////////////////////////////////////////// 293 | /// Deploy to Google 294 | /// ////////////////////////////////////////////////////////// 295 | let googleUrl 296 | if (isToGoogle === true) { 297 | googleUrl = await deployPublishGoogle( 298 | exp, 299 | // TODO lol 300 | parsedHyperformJson.google.gc_region, 301 | parsedHyperformJson.google.gc_project, // TODO 302 | googleZipPath, 303 | isPublic, 304 | ) 305 | } 306 | 307 | return amazonUrl || googleUrl // for tests etc 308 | }), 309 | ) 310 | 311 | endpoints = endpoints.filter((el) => el) 312 | 313 | return { urls: endpoints } 314 | 315 | /// ////////////////////////////////////////////////////////// 316 | // Bundle and zip for Google (once) // 317 | /// ////////////////////////////////////////////////////////// 318 | 319 | // TODO 320 | 321 | // NOTE that google and amazon now work fundamentally different 322 | // Google - 1 deployment package 323 | 324 | // For each file 325 | // bundle 326 | // transpile 327 | // // Amazon 328 | // // zip 329 | // // deployAmazon 330 | // // publishAmazon 331 | 332 | // // Later instead of N times, just create 1 deployment package for all functions 333 | 334 | // const endpoints = await Promise.all( 335 | // // For each file 336 | // infos.map(async (info) => { 337 | // const toAmazon = parsedHyperformJson.amazon != null 338 | // const toGoogle = parsedHyperformJson.google != null 339 | // /// ////////////////////////////////////////////////////////// 340 | // // Bundle and zip for Amazon // 341 | // /// ////////////////////////////////////////////////////////// 342 | // let amazonZipPath 343 | // if (toAmazon === true) { 344 | // amazonZipPath = await bundleTranspileZipAmazon(info.p) 345 | // } 346 | 347 | // /// ////////////////////////////////////////////////////////// 348 | // // Bundle and zip for Google // 349 | // // NOW DONE ABOVE 350 | // /// ////////////////////////////////////////////////////////// 351 | // // let googleZipPath 352 | // // if (toGoogle === true) { 353 | // // googleZipPath = await bundleTranspileZipGoogle(info.p) 354 | // // } 355 | 356 | // // For each matching export 357 | // const endpts = await Promise.all( 358 | // info.exps.map(async (exp, idx) => { 359 | // /// ////////////////////////////////////////////////////////// 360 | // /// Deploy to Amazon 361 | // /// ////////////////////////////////////////////////////////// 362 | // let amazonUrl 363 | // if (toAmazon === true) { 364 | // amazonUrl = await deployPublishAmazon( 365 | // exp, 366 | // parsedHyperformJson.amazon.aws_region, 367 | // amazonZipPath, 368 | // isPublic, 369 | // ) 370 | // } 371 | 372 | // /// ////////////////////////////////////////////////////////// 373 | // /// Deploy to Google 374 | // /// ////////////////////////////////////////////////////////// 375 | // let googleUrl 376 | // if (toGoogle === true) { 377 | // googleUrl = await deployPublishGoogle( 378 | // exp, 379 | // 'us-central1', 380 | // 'hyperform-7fd42', // TODO 381 | // googleZipPath, 382 | // isPublic, 383 | // ) 384 | // } 385 | 386 | // return [amazonUrl, googleUrl].filter((el) => el) // for tests etc 387 | // }), 388 | // ) 389 | 390 | // return [].concat(...endpts) 391 | // }), 392 | // ) 393 | // return { urls: endpoints } 394 | } 395 | 396 | module.exports = { 397 | main, 398 | } 399 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop, global-require */ 2 | 3 | // One test to rule them all 4 | 5 | const os = require('os') 6 | const path = require('path') 7 | const fsp = require('fs').promises 8 | 9 | const TIMEOUT = 1 * 60 * 1000 10 | 11 | describe('System tests (takes 1-2 minutes)', () => { 12 | describe('main', () => { 13 | describe('amazon', () => { 14 | test('completes', async () => { 15 | const { main } = require('./index') 16 | /// //////////////////////////////////////////// 17 | // Set up 18 | 19 | const tmpdir = path.join( 20 | os.tmpdir(), 21 | `${Math.ceil(Math.random() * 100000000000)}`, 22 | ) 23 | 24 | // // What we will pass to the functions 25 | 26 | // const random_string = uuidv4() 27 | // const event_body = { 28 | // random_string, 29 | // } 30 | // const event_querystring = `?random_string=${random_string}` 31 | 32 | // Create javascript files 33 | 34 | await fsp.mkdir(tmpdir) 35 | const code = ` 36 | function irrelevant() { 37 | return 100 38 | } 39 | 40 | function jest_systemtest_amazon(event, context, callback) { 41 | context.succeed({}) 42 | } 43 | 44 | module.exports = { 45 | jest_systemtest_amazon 46 | } 47 | ` 48 | 49 | const tmpcodepath = path.join(tmpdir, 'index.js') 50 | await fsp.writeFile(tmpcodepath, code, { encoding: 'utf-8' }) 51 | 52 | const arg1 = tmpdir 53 | const arg2 = tmpcodepath 54 | /// //////////////////////////////////////////// 55 | // Run main for Amazon 56 | /// //////////////////////////////////////////// 57 | let amazonMainRes 58 | 59 | const amazonarg3 = 'amazon' 60 | const amazonarg4 = { 61 | amazon: { 62 | aws_access_key_id: process.env.AWS_ACCESS_KEY_ID, 63 | aws_secret_access_key: process.env.AWS_SECRET_ACCESS_KEY, 64 | aws_region: process.env.AWS_REGION, 65 | }, 66 | 67 | } 68 | 69 | let err 70 | try { 71 | amazonMainRes = await main(arg1, arg2, amazonarg3, amazonarg4) 72 | } catch (e) { 73 | console.log(e) 74 | err = e 75 | } 76 | 77 | // Expect main did not throw 78 | expect(err).not.toBeDefined() 79 | // // Expect main returned sensible data 80 | // expect(amazonMainRes).toBeDefined() 81 | // expect(amazonMainRes.urls).toBeDefined() 82 | }, TIMEOUT) 83 | }) 84 | 85 | describe('google', () => { 86 | test('completes', async () => { 87 | const { main } = require('./index') 88 | /// //////////////////////////////////////////// 89 | // Set up 90 | 91 | const tmpdir = path.join( 92 | os.tmpdir(), 93 | `${Math.ceil(Math.random() * 100000000000)}`, 94 | ) 95 | 96 | // // What we will pass to the functions 97 | 98 | // const random_string = uuidv4() 99 | // const event_body = { 100 | // random_string, 101 | // } 102 | // const event_querystring = `?random_string=${random_string}` 103 | 104 | // Create javascript files 105 | 106 | await fsp.mkdir(tmpdir) 107 | const code = ` 108 | function irrelevant() { 109 | return 100 110 | } 111 | 112 | function jest_systemtest_google(req, resp) { 113 | resp.json({}) 114 | } 115 | 116 | module.exports = { 117 | jest_systemtest_google 118 | } 119 | ` 120 | 121 | const tmpcodepath = path.join(tmpdir, 'index.js') 122 | await fsp.writeFile(tmpcodepath, code, { encoding: 'utf-8' }) 123 | 124 | const arg1 = tmpdir 125 | const arg2 = tmpcodepath 126 | /// //////////////////////////////////////////// 127 | // Run main for Google 128 | /// //////////////////////////////////////////// 129 | let googleMainRes 130 | 131 | const googlearg3 = 'google' 132 | const googlearg4 = { 133 | google: { 134 | gc_project: process.env.GC_PROJECT, 135 | gc_region: process.env.GC_REGION, 136 | }, 137 | } 138 | 139 | let err 140 | try { 141 | googleMainRes = await main(arg1, arg2, googlearg3, googlearg4) 142 | } catch (e) { 143 | console.log(e) 144 | err = e 145 | } 146 | 147 | // Expect main did not throw 148 | expect(err).not.toBeDefined() 149 | // // Expect main returned sensible data 150 | // expect(googleMainRes).toBeDefined() 151 | // expect(googleMainRes.urls).toBeDefined() 152 | }, TIMEOUT) 153 | }) 154 | // test('completes, and echo endpoints return first arg (event) and second arg (http) on GET and POST', async () => { 155 | // const { main } = require('./index') 156 | // /// //////////////////////////////////////////// 157 | // // Set up 158 | 159 | // const tmpdir = path.join( 160 | // os.tmpdir(), 161 | // `${Math.ceil(Math.random() * 100000000000)}`, 162 | // ) 163 | 164 | // // // What we will pass to the functions 165 | 166 | // // const random_string = uuidv4() 167 | // // const event_body = { 168 | // // random_string, 169 | // // } 170 | // // const event_querystring = `?random_string=${random_string}` 171 | 172 | // // Create javascript files 173 | 174 | // await fsp.mkdir(tmpdir) 175 | // const code = ` 176 | // function irrelevant() { 177 | // return 100 178 | // } 179 | 180 | // function jest_systemtest_echo(event, http) { 181 | // return { event: event, http: http } 182 | // } 183 | 184 | // module.exports = { 185 | // jest_systemtest_echo 186 | // } 187 | // ` 188 | 189 | // const tmpcodepath = path.join(tmpdir, 'index.js') 190 | // await fsp.writeFile(tmpcodepath, code, { encoding: 'utf-8' }) 191 | 192 | // const arg1 = tmpdir 193 | // const arg2 = tmpcodepath 194 | // /// //////////////////////////////////////////// 195 | // // Run main for Amazon 196 | // /// //////////////////////////////////////////// 197 | // let amazonMainRes 198 | 199 | // { 200 | // const amazonarg3 = { 201 | // amazon: { 202 | // aws_access_key_id: process.env.AWS_ACCESS_KEY_ID, 203 | // aws_secret_access_key: process.env.AWS_SECRET_ACCESS_KEY, 204 | // aws_region: process.env.AWS_REGION, 205 | // }, 206 | 207 | // } 208 | 209 | // // isPublic 210 | // const amazonarg4 = true 211 | 212 | // let err 213 | // try { 214 | // amazonMainRes = await main(arg1, arg2, amazonarg3, amazonarg4) 215 | // } catch (e) { 216 | // console.log(e) 217 | // err = e 218 | // } 219 | 220 | // // Expect main did not throw 221 | // expect(err).not.toBeDefined() 222 | // // Expect main returned sensible data 223 | // expect(amazonMainRes).toBeDefined() 224 | // expect(amazonMainRes.urls).toBeDefined() 225 | // } 226 | 227 | // /// //////////////////////////////////////////// 228 | // // Run main for Google 229 | // /// //////////////////////////////////////////// 230 | // let googleMainRes 231 | // { 232 | // const googlearg3 = { 233 | // google: { 234 | // gc_client_email: '', 235 | // gc_private_key: '', 236 | // gc_project: '', 237 | // }, 238 | 239 | // } 240 | 241 | // // to test publishing too 242 | // const googlearg4 = true 243 | 244 | // let err 245 | // try { 246 | // googleMainRes = await main(arg1, arg2, googlearg3, googlearg4) 247 | // } catch (e) { 248 | // console.log(e) 249 | // err = e 250 | // } 251 | 252 | // // Expect main did not throw 253 | // expect(err).not.toBeDefined() 254 | // // Expect main returned sensible data 255 | // expect(googleMainRes).toBeDefined() 256 | // expect(googleMainRes.urls).toBeDefined() 257 | // } 258 | 259 | // /// /////////////////////////////////////////// 260 | // // Ping each Amazon URL 261 | // // Expect correct result 262 | // /// /////////////////////////////////////////// 263 | // // Don't test Google ones, they take another 1-2min to be ready 264 | // const urls = [].concat(...amazonMainRes.urls) 265 | // // TODO ensure in deployGoogle we return only on truly completed 266 | // // TODO then, we can start testing them here again 267 | 268 | // for (let i = 0; i < urls.length; i += 1) { 269 | // const url = urls[i] 270 | // /// ///////////////// 271 | // // POST ///////////// 272 | // /// ///////////////// 273 | 274 | // { 275 | // const postres = await fetch(url, { 276 | // method: 'POST', 277 | // body: JSON.stringify(event_body), 278 | // }) 279 | // const statusCode = postres.status 280 | // const actualResult = await postres.json() 281 | // // HTTP Code 2XX 282 | // expect(/^2/.test(statusCode)).toBe(true) 283 | // // Echoed event 284 | // expect(actualResult.event).toEqual(event_body) 285 | // // Returned second argument; check if the wrapper formed it properly 286 | // expect(actualResult.http).toBeDefined() 287 | // expect(actualResult.http.headers).toBeDefined() 288 | // expect(actualResult.http.method).toBe('POST') 289 | // } 290 | 291 | // /// ///////////////// 292 | // // GET ///////////// 293 | // /// ///////////////// 294 | 295 | // { 296 | // const getres = await fetch(`${url}${event_querystring}`, { 297 | // method: 'GET', 298 | // }) 299 | // const statusCode = getres.status 300 | // const actualResult = await getres.json() 301 | // // HTTP Code 2XX 302 | // expect(/^2/.test(statusCode)).toBe(true) 303 | // // Echoed event 304 | // expect(actualResult.event).toEqual(event_body) 305 | // // Returned second argument; check if the wrapper formed it properly 306 | // expect(actualResult.http).toBeDefined() 307 | // expect(actualResult.http.headers).toBeDefined() 308 | // expect(actualResult.http.method).toBe('GET') 309 | // } 310 | // } 311 | // }, TIMEOUT) 312 | }) 313 | 314 | // describe('cli', () => { 315 | // // TODO 316 | // }) 317 | }) 318 | -------------------------------------------------------------------------------- /initer/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const os = require('os') 4 | const { EOL } = require('os') 5 | const { log, logdev } = require('../printers/index') 6 | /** 7 | * @description Extracts the [default] section of an AWS .aws/config or .aws/credentials file 8 | * @param {string} filecontents File contents of an .aws/credentials or .aws/config file 9 | * @returns {string} The string between [default] and the next [...] header, if exists. 10 | * Otherwise returns empty string 11 | */ 12 | function getDefaultSectionString(filecontents) { 13 | // Collect all lines below the [default] header ... 14 | let defaultSection = filecontents.split(/\[default\]/)[1] 15 | 16 | if (typeof defaultSection !== 'string' || !defaultSection.trim()) { 17 | // default section is non-existent 18 | return '' 19 | } 20 | // ... but above the next [...] header (if any) 21 | defaultSection = defaultSection.split(/\[[^\]]*\]/)[0] 22 | return defaultSection 23 | } 24 | 25 | // TODO refactor 26 | // TODO split up into better functions, for amazon, google inferrer 27 | // TODO error handling & meaningful stdout 28 | // TODO tests 29 | /** 30 | * @description Extracts aws_access_key_id and aws_secret_access_key fields from a given .aws/credentials file 31 | * @param {string} filecontents File contents of .aws/credentials 32 | * @returns { 33 | * default: { 34 | * aws_access_key_id?: string, 35 | * aws_secret_access_key?: string, 36 | * aws_region?: string 37 | * }} 38 | */ 39 | function parseAwsCredentialsOrConfigFile(filecontents) { 40 | /* filecontents looks something like this: 41 | 42 | [default] 43 | region=us-west-2 44 | output=json 45 | 46 | [profile user1] 47 | region=us-east-1 48 | output=text 49 | 50 | See: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html 51 | 52 | */ 53 | 54 | const fields = { 55 | default: {}, 56 | } 57 | 58 | try { 59 | const defaultSectionString = getDefaultSectionString(filecontents) 60 | if (!defaultSectionString || !defaultSectionString.trim()) { 61 | return fields 62 | } 63 | 64 | // we found something 65 | const defaultSectionLines = defaultSectionString.split('\n') // TODO split by os-specific newline 66 | 67 | // Try to extract aws_access_key_id 68 | if (defaultSectionLines.some((l) => /aws_access_key_id/.test(l))) { 69 | const awsAccessKeyIdLine = defaultSectionLines.filter((l) => /aws_access_key_id/.test(l))[0] 70 | let awsAccessKeyId = awsAccessKeyIdLine.split('=')[1] 71 | if (typeof awsAccessKeyId === 'string') { // don't crash on weird invalid lines such 'aws_access_key_id=' or 'aws_access_key_id' 72 | awsAccessKeyId = awsAccessKeyId.trim() 73 | fields.default.aws_access_key_id = awsAccessKeyId 74 | } 75 | } 76 | 77 | // Try to extract aws_secret_access_key 78 | if (defaultSectionLines.some((l) => /aws_secret_access_key/.test(l))) { 79 | const awsSecretAccessKeyLine = defaultSectionLines.filter((l) => /aws_secret_access_key/.test(l))[0] 80 | let awsSecretAccessKey = awsSecretAccessKeyLine.split('=')[1] 81 | if (typeof awsSecretAccessKey === 'string') { 82 | awsSecretAccessKey = awsSecretAccessKey.trim() 83 | fields.default.aws_secret_access_key = awsSecretAccessKey 84 | } 85 | } 86 | 87 | // Try to extract region 88 | if (defaultSectionLines.some((l) => /region/.test(l))) { 89 | const regionLine = defaultSectionLines.filter((l) => /region/.test(l))[0] 90 | let region = regionLine.split('=')[1] 91 | if (typeof region === 'string') { 92 | region = region.trim() 93 | fields.default.region = region 94 | } 95 | } 96 | 97 | return fields 98 | // 99 | } catch (e) { 100 | // console.log(e) 101 | // non-critical, just return what we have so far 102 | return fields 103 | } 104 | } 105 | 106 | /** 107 | * Just creates an empty hyperform.json 108 | * @param {string} absdir 109 | */ 110 | function initDumb(absdir, platform) { 111 | let json 112 | if (platform === 'amazon') { 113 | json = { 114 | amazon: { 115 | aws_access_key_id: '', 116 | aws_secret_access_key: '', 117 | aws_region: '', 118 | }, 119 | } 120 | } else if (platform === 'google') { 121 | json = { 122 | google: { 123 | gc_project: '', 124 | gc_region: '', 125 | }, 126 | } 127 | } else { 128 | throw new Error(`platform must be google or amazon but is ${platform}`) 129 | } 130 | 131 | // append 'hyperform.json' to .gitignore 132 | // (or create .gitignore if it does not exist yet) 133 | fs.appendFileSync( 134 | path.join(absdir, '.gitignore'), 135 | `${EOL}hyperform.json`, 136 | ) 137 | 138 | // write results to hyperform.json 139 | fs.writeFileSync( 140 | path.join(absdir, 'hyperform.json'), 141 | JSON.stringify(json, null, 2), 142 | ) 143 | log('✓ Created `hyperform.json` ') // 144 | log('✓ Added `hyperform.json` to `.gitignore`') // 145 | } 146 | 147 | // TODO shorten 148 | /** 149 | * @description Tries to infer AWS credentials and config, and creates a hyperform.json in "absdir" with what it could infer. If hyperform.json already exists in "absdir" it just prints a message. 150 | * @param {string} absdir The directory where 'hyperform.json' should be created 151 | * @returns {{ 152 | * amazon: { 153 | * aws_access_key_id: string?, 154 | * aws_secret_access_key: string?, 155 | * aws_region: string? 156 | * } 157 | * }} 158 | */ 159 | function init(absdir) { 160 | const hyperformJsonContents = { 161 | amazon: { 162 | aws_access_key_id: '', 163 | aws_secret_access_key: '', 164 | aws_region: '', 165 | }, 166 | google: { 167 | gc_project: '', 168 | gc_region: '', 169 | }, 170 | } 171 | 172 | const filedest = path.join(absdir, 'hyperform.json') 173 | if (fs.existsSync(filedest)) { 174 | log('hyperform.json exists already.') 175 | return 176 | } 177 | 178 | // try to infer AWS credentials 179 | 180 | // AWS CLI uses this precedence: 181 | // (1 - highest precedence) Environment variables AWS_ACCESS_KEY_ID, ... 182 | // (2) .aws/credentials and .aws/config 183 | 184 | // Hence, do the same here 185 | 186 | // First, start with (2) 187 | 188 | // Check ~/.aws/credentials and ~/.aws/config 189 | 190 | const possibleCredentialsPath = path.join(os.homedir(), '.aws', 'credentials') 191 | 192 | if (fs.existsSync(possibleCredentialsPath) === true) { 193 | const credentialsFileContents = fs.readFileSync(possibleCredentialsPath, { encoding: 'utf-8' }) 194 | 195 | // TODO offer selection to user when there are multiple profiles 196 | const parsedCredentials = parseAwsCredentialsOrConfigFile(credentialsFileContents) 197 | hyperformJsonContents.amazon.aws_access_key_id = parsedCredentials.default.aws_access_key_id 198 | hyperformJsonContents.amazon.aws_secret_access_key = parsedCredentials.default.aws_secret_access_key 199 | logdev(`Inferred AWS credentials from ${possibleCredentialsPath}`) 200 | } else { 201 | logdev(`Could not guess AWS credentials. No AWS credentials file found in ${possibleCredentialsPath}`) 202 | } 203 | 204 | /// ///////////////// 205 | /// ///////////////// 206 | 207 | // try to infer AWS region 208 | const possibleConfigPath = path.join(os.homedir(), '.aws', 'config') 209 | 210 | if (fs.existsSync(possibleConfigPath) === true) { 211 | const configFileContents = fs.readFileSync(possibleConfigPath, { encoding: 'utf-8' }) 212 | 213 | const parsedConfig = parseAwsCredentialsOrConfigFile(configFileContents) 214 | hyperformJsonContents.amazon.aws_region = parsedConfig.default.region 215 | logdev(`Inferred AWS region from ${possibleConfigPath}`) 216 | } else { 217 | logdev(`Could not guess AWS region. No AWS config file found in ${possibleConfigPath}`) // TODO region will not be a single region, but smartly multiple ones (or?) 218 | } 219 | 220 | // Then, do (1), possibly overriding values 221 | // Check environment variables 222 | 223 | if (typeof process.env.AWS_ACCESS_KEY_ID === 'string' && process.env.AWS_ACCESS_KEY_ID.trim().length > 0) { 224 | hyperformJsonContents.amazon.aws_access_key_id = process.env.AWS_ACCESS_KEY_ID.trim() 225 | logdev('Environment variable AWS_ACCESS_KEY_ID set, overriding value from credentials file') 226 | } 227 | 228 | if (typeof process.env.AWS_SECRET_ACCESS_KEY === 'string' && process.env.AWS_SECRET_ACCESS_KEY.trim().length > 0) { 229 | hyperformJsonContents.amazon.aws_secret_access_key = process.env.AWS_SECRET_ACCESS_KEY.trim() 230 | logdev('Environment variable AWS_SECRET_ACCESS_KEY set, overriding value from credentials file') 231 | } 232 | 233 | if (typeof process.env.AWS_REGION === 'string' && process.env.AWS_REGION.trim().length > 0) { 234 | hyperformJsonContents.amazon.aws_region = process.env.AWS_REGION.trim() 235 | logdev('Environment variable AWS_REGION set, overriding value from config file') 236 | } 237 | 238 | // append 'hyperform.json' to .gitignore 239 | // (or create .gitignore if it does not exist yet) 240 | fs.appendFileSync( 241 | path.join(absdir, '.gitignore'), 242 | `${EOL}hyperform.json`, 243 | ) 244 | 245 | // write results to hyperform.json 246 | fs.writeFileSync( 247 | path.join(absdir, 'hyperform.json'), 248 | JSON.stringify(hyperformJsonContents, null, 2), 249 | ) 250 | log('✓ Inferred AWS credentials (\'default\' Profile)') // TODO ask for defaults guide through in init 251 | 252 | log('✓ Created hyperform.json') // TODO ask for defaults guide through in init 253 | } 254 | 255 | module.exports = { 256 | init, 257 | initDumb, 258 | _only_for_testing_getDefaultSectionString: getDefaultSectionString, 259 | _only_for_testing_parseAwsCredentialsOrConfigFile: parseAwsCredentialsOrConfigFile, 260 | } 261 | -------------------------------------------------------------------------------- /initer/index.test.js: -------------------------------------------------------------------------------- 1 | describe('initer', () => { 2 | describe('getDefaultSectionString', () => { 3 | test('returns string on input: empty string', () => { 4 | const getDefaultSectionString = require('./index')._only_for_testing_getDefaultSectionString 5 | 6 | const filecontents = ' ' 7 | 8 | const res = getDefaultSectionString(filecontents) 9 | 10 | expect(typeof res).toBe('string') 11 | expect(res.trim()).toBe('') 12 | }) 13 | 14 | test('returns empty string on input: [default] header', () => { 15 | const getDefaultSectionString = require('./index')._only_for_testing_getDefaultSectionString 16 | 17 | const filecontents = ` 18 | [defaut] 19 | 20 | ` 21 | 22 | const res = getDefaultSectionString(filecontents) 23 | 24 | expect(typeof res).toBe('string') 25 | expect(res.trim()).toBe('') 26 | }) 27 | 28 | test('returns empty string on input: other header, other section', () => { 29 | const getDefaultSectionString = require('./index')._only_for_testing_getDefaultSectionString 30 | 31 | const filecontents = ` 32 | [some-other-header] 33 | first 34 | second 35 | 36 | ` 37 | 38 | const res = getDefaultSectionString(filecontents) 39 | 40 | expect(typeof res).toBe('string') 41 | expect(res.trim()).toBe('') 42 | }) 43 | 44 | test('returns section on input: [default] header and section', () => { 45 | const getDefaultSectionString = require('./index')._only_for_testing_getDefaultSectionString 46 | 47 | const filecontents = ` 48 | [default] 49 | first 50 | second 51 | ` 52 | 53 | const res = getDefaultSectionString(filecontents) 54 | 55 | expect(typeof res).toBe('string') 56 | const text = res.trim() // 'first\nsecond' 57 | expect(/first/.test(text)).toBe(true) 58 | expect(/second/.test(text)).toBe(true) 59 | }) 60 | 61 | test('returns section on input: other section 1, [default] header, section, other section 2', () => { 62 | const getDefaultSectionString = require('./index')._only_for_testing_getDefaultSectionString 63 | 64 | const filecontents = ` 65 | [some-other-profile-a] 66 | sky 67 | air 68 | [default] 69 | first 70 | second 71 | [some-other-profile-b] 72 | clouds 73 | ` 74 | 75 | const res = getDefaultSectionString(filecontents) 76 | 77 | expect(typeof res).toBe('string') 78 | const text = res.trim() 79 | // We want all between [default] and next header, but nothing else 80 | expect(text).toBe('first\nsecond') 81 | }) 82 | }) 83 | 84 | describe('parseAwsCredentialsOrConfigFile', () => { 85 | test('returns default credentials on just [default] section present', () => { 86 | const parseAwsCredentialsOrConfigFile = require('./index')._only_for_testing_parseAwsCredentialsOrConfigFile 87 | 88 | const filecontents = ` 89 | [default] 90 | aws_access_key_id = AKIA2WOM6JAHXXXXXXXX 91 | aws_secret_access_key = XXXXXXXXXX+tgppEZPzdN/XXXXlXXXXX/XXXXXXX 92 | 93 | ` 94 | const res = parseAwsCredentialsOrConfigFile(filecontents) 95 | 96 | expect(res).toBeDefined() 97 | expect(res.default).toBeDefined() 98 | expect(res.default.aws_access_key_id).toBe('AKIA2WOM6JAHXXXXXXXX') 99 | expect(res.default.aws_secret_access_key).toBe('XXXXXXXXXX+tgppEZPzdN/XXXXlXXXXX/XXXXXXX') 100 | }) 101 | 102 | test('returns default credentials on multiple sections present', () => { 103 | const parseAwsCredentialsOrConfigFile = require('./index')._only_for_testing_parseAwsCredentialsOrConfigFile 104 | 105 | // the more weirdly formed 106 | const filecontents = ` 107 | [some-other-section-a] 108 | some-other-section-field-b=1234567890 109 | [default] 110 | aws_access_key_id= AKIA2WOM6JAHXXXXXXXX 111 | aws_secret_access_key =XXXXXXXXXX+tgppEZPzdN/XXXXlXXXXX/XXXXXXX 112 | [some-other-section-b] 113 | some-other-section-field-b=098765434567898765 114 | ` 115 | const res = parseAwsCredentialsOrConfigFile(filecontents) 116 | 117 | expect(res).toBeDefined() 118 | expect(res.default).toBeDefined() 119 | expect(res.default.aws_access_key_id).toBe('AKIA2WOM6JAHXXXXXXXX') 120 | expect(res.default.aws_secret_access_key).toBe('XXXXXXXXXX+tgppEZPzdN/XXXXlXXXXX/XXXXXXX') 121 | }) 122 | }) 123 | 124 | describe('init', () => { 125 | // TODO create mock .aws and see if fields are extracted correctly 126 | test('runs, and output has expected structure', async () => { 127 | const os = require('os') 128 | const uuidv4 = require('uuid').v4 129 | const fs = require('fs') 130 | const path = require('path') 131 | const { init } = require('./index') 132 | // init will write hyperform.json here 133 | const absdir = path.join(os.tmpdir(), uuidv4()) 134 | fs.mkdirSync(absdir) 135 | 136 | let err 137 | try { 138 | init(absdir) 139 | } catch (e) { 140 | console.log(e) 141 | err = e 142 | } 143 | 144 | // it didn't throw 145 | expect(err).not.toBeDefined() 146 | 147 | // it wrote hyperform.json 148 | const hyperformJsonPath = path.join(absdir, 'hyperform.json') 149 | expect(fs.existsSync(hyperformJsonPath)).toBe(true) 150 | 151 | // hyperform.json has the expected structure 152 | let hyperformJson = fs.readFileSync(hyperformJsonPath) 153 | hyperformJson = JSON.parse(hyperformJson) 154 | 155 | expect(hyperformJson.amazon).toBeDefined() 156 | expect(hyperformJson.amazon.aws_access_key_id).toBeDefined() 157 | expect(hyperformJson.amazon.aws_secret_access_key).toBeDefined() 158 | expect(hyperformJson.amazon.aws_region).toBeDefined() 159 | }) 160 | }) 161 | }) 162 | -------------------------------------------------------------------------------- /kindler/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | const path = require('path') 3 | 4 | /** 5 | * Appends code to an index.js ("code") in "dir" that imports 6 | * and immediately exports given "infos". Has no side effects. 7 | * Needed for Google that only looks into index.js 8 | * @param {string} code 9 | * @param {string} dir 10 | * @param {[ {p: string, exps: string[] }]} infos For instance [ 11 | { 12 | p: '/home/qng/dir/somefile.js', 13 | exps: [ 'endpoint_hello' ] 14 | } 15 | ] 16 | * */ 17 | function kindle(code, dir, infos) { 18 | const kindleAppendix = ` 19 | ;module.exports = { 20 | ${ 21 | // for each file 22 | infos.map(({ p, exps }) => { 23 | // for each endpoint export 24 | return exps.map((exp) => { 25 | const relPath = path.relative(dir, p) 26 | // it's exported from index.js, whose source code this will be (ie above) 27 | if (relPath === 'index.js') { 28 | return `${exp}: module.exports.${exp} || exports.${exp},` 29 | } else { 30 | // it's exported from other file 31 | return `${exp}: require('./${relPath}').${exp},` 32 | } 33 | }) 34 | .join('\n') 35 | }) 36 | .join('\n') 37 | } 38 | }; 39 | ` 40 | 41 | const kindledCode = ` 42 | ${code} 43 | ${kindleAppendix} 44 | ` 45 | return kindledCode 46 | } 47 | 48 | module.exports = { 49 | kindle, 50 | } 51 | -------------------------------------------------------------------------------- /kindler/index.tes.js: -------------------------------------------------------------------------------- 1 | // TODO 2 | -------------------------------------------------------------------------------- /meta/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Detects whether jest is running this code 3 | * @returns {boolean} 4 | */ 5 | function isInTesting() { 6 | if (process.env.JEST_WORKER_ID != null) { 7 | return true 8 | } 9 | if (process.env.NODE_ENV === 'test') { 10 | return true 11 | } 12 | return false 13 | } 14 | 15 | module.exports = { 16 | isInTesting, 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperform-cli", 3 | "version": "0.6.12", 4 | "description": "", 5 | "main": "index.js", 6 | "bin": { 7 | "hf": "./cli.js" 8 | }, 9 | "scripts": { 10 | "test": "jest --runInBand --setupFiles dotenv/config --coverage", 11 | "lint": "eslint", 12 | "lintfix": "eslint --fix" 13 | }, 14 | "engines": { 15 | "node": ">=10.10.0" 16 | }, 17 | "jest": { 18 | "testEnvironment": "node" 19 | }, 20 | "engineStrict": true, 21 | "author": "", 22 | "license": "Apache 2.0", 23 | "dependencies": { 24 | "@google-cloud/functions": "^1.1.2", 25 | "aws-sdk": "^2.820.0", 26 | "chalk": "^4.1.0", 27 | "clipboardy": "^2.3.0", 28 | "dotenv": "^8.2.0", 29 | "findit": "^2.0.0", 30 | "joi": "^17.3.0", 31 | "ncp": "^2.0.0", 32 | "node-fetch": "^2.6.1", 33 | "semver": "^7.3.5", 34 | "spinnies": "^0.5.1", 35 | "uuid": "^8.3.2", 36 | "webpack": "^5.4.0", 37 | "yazl": "^2.5.1", 38 | "zip-dir": "^2.0.0" 39 | }, 40 | "devDependencies": { 41 | "eslint": "^7.15.0", 42 | "eslint-config-airbnb-base": "^14.2.1", 43 | "eslint-plugin-import": "^2.22.1", 44 | "husky": "^4.3.6", 45 | "jest": "25.0.0", 46 | "webpack-cli": "^4.2.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /parser/index.js: -------------------------------------------------------------------------------- 1 | // validates and parses hyperform.json 2 | 3 | const path = require('path') 4 | const { amazonSchema, googleSchema } = require('../schemas/index') 5 | 6 | let parsedHyperformJson 7 | 8 | /** 9 | * @description Parses v,alidates, and returns contents of "dir"/hyperform.json 10 | * @param {string} dir Directory where to look for hyperform.json 11 | * @param {string} platform Whether to expect 'amazon' or 'google' content 12 | * 13 | */ 14 | function getParsedHyperformJson(dir, platform) { 15 | if (parsedHyperformJson == null) { 16 | const json = require(path.join(dir, 'hyperform.json')) 17 | // validate its schema 18 | let schema 19 | if (platform === 'amazon') schema = amazonSchema 20 | if (platform === 'google') schema = googleSchema 21 | // throws if platform is not 'amazon' or 'google' 22 | const { error, value } = schema.validate(json) 23 | if (error) { 24 | throw new Error(`${error} ${value}`) 25 | } 26 | parsedHyperformJson = json 27 | } 28 | 29 | return parsedHyperformJson 30 | } 31 | 32 | module.exports = { 33 | getParsedHyperformJson, 34 | } 35 | -------------------------------------------------------------------------------- /printers/index.js: -------------------------------------------------------------------------------- 1 | const Spinnies = require('spinnies') 2 | const { isInTesting } = require('../meta/index') 3 | 4 | const spinner = { 5 | interval: 80, 6 | frames: [ 7 | '⠁', 8 | '⠂', 9 | '⠄', 10 | '⡀', 11 | '⢀', 12 | '⠠', 13 | '⠐', 14 | '⠈', 15 | ], 16 | } 17 | 18 | const spinnies = new Spinnies({ color: 'white', succeedColor: 'white', spinner: spinner }); 19 | 20 | const { log } = console 21 | let logdev 22 | 23 | // Don't show dev-level logging 24 | // (Comment out to show dev-level logging) 25 | logdev = () => { } 26 | // Don't show timings 27 | // (Comment out to see timings) 28 | console.time = () => { } 29 | console.timeEnd = () => { } 30 | 31 | // In testing, be silent but console.log successes and fails 32 | if (isInTesting() === true) { 33 | spinnies.add = () => { } 34 | spinnies.update = () => { } 35 | spinnies.remove = () => { } 36 | spinnies.succeed = (_, { text }) => console.log(text) 37 | spinnies.fail = (_, { text }) => console.log(text) 38 | spinnies.updateSpinnerState = () => {} 39 | } 40 | 41 | spinnies.f = spinnies.fail 42 | spinnies.succ = spinnies.succeed 43 | 44 | module.exports = { 45 | spinnies, 46 | log, 47 | logdev, 48 | } 49 | -------------------------------------------------------------------------------- /publisher/amazon/index.js: -------------------------------------------------------------------------------- 1 | const { 2 | createApi, 3 | createIntegration, 4 | createDefaultAutodeployStage, 5 | setRoute, 6 | allowApiGatewayToInvokeLambda, 7 | getApiDetails, 8 | } = require('./utils') 9 | // TODO handle regional / edge / read up on how edge works 10 | 11 | const HFAPINAME = 'hyperform-v1' 12 | 13 | /** 14 | * @description Creates a public HTTP endpoint that forwards request to a given Lambda. 15 | * @param {string} lambdaArn 16 | * @param {string} region 17 | * @returns {Promise} Full endpoint URL, eg. https://48ytz1e6f3.execute-api.us-east-2.amazonaws.com/endpoint-hello 18 | */ 19 | async function publishAmazon(lambdaArn, region) { 20 | // console.log('received lambdaar', lambdaArn) 21 | const lambdaName = lambdaArn.split(':').slice(-1)[0] 22 | // Lambda 'endpoint-hello' should be at 'https://.../endpoint-hello' 23 | const routePath = `/${lambdaName}` 24 | 25 | /// /////////////////////////////////////////////// 26 | /// //ensure HF API exists in that region ///////// 27 | // TODO to edge 28 | /// ////////////////////////////////////////////// 29 | 30 | let hfApiId 31 | let hfApiUrl 32 | 33 | /// /////////////////////////////////////////////// 34 | /// Check if HF umbrella API exists in that region 35 | /// ////////////////////////////////////////////// 36 | 37 | const apiDetails = await getApiDetails(HFAPINAME, region) 38 | // exists 39 | // use it 40 | if (apiDetails != null && apiDetails.apiId != null) { 41 | hfApiId = apiDetails.apiId 42 | hfApiUrl = apiDetails.apiUrl 43 | // does not exist 44 | // create HF API 45 | } else { 46 | const createRes = await createApi(HFAPINAME, region) 47 | hfApiId = createRes.apiId 48 | hfApiUrl = createRes.apiUrl 49 | } 50 | 51 | /// /////////////////////////////////////////////// 52 | /// Add permission to API to lambda accessed by API gateway 53 | /// ////////////////////////////////////////////// 54 | 55 | // todo iwann spezifisch der api access der lambda erlauben via SourceArn 56 | await allowApiGatewayToInvokeLambda(lambdaName, region) 57 | 58 | /// /////////////////////////////////////////////// 59 | /// Create integration that represents the Lambda 60 | /// ////////////////////////////////////////////// 61 | 62 | const integrationId = await createIntegration(hfApiId, region, lambdaArn) 63 | 64 | /// /////////////////////////////////////////////// 65 | /// Create $default Auto-Deploy stage 66 | /// ////////////////////////////////////////////// 67 | 68 | try { 69 | await createDefaultAutodeployStage(hfApiId, region) 70 | } catch (e) { 71 | // already exists (shouldn't throw because of anything other) 72 | // nice 73 | } 74 | 75 | /// /////////////////////////////////////////////// 76 | /// Create / update route with that integration 77 | /// ////////////////////////////////////////////// 78 | 79 | await setRoute( 80 | hfApiId, 81 | region, 82 | routePath, 83 | integrationId, 84 | ) 85 | 86 | const endpointUrl = hfApiUrl + routePath 87 | 88 | return endpointUrl 89 | } 90 | 91 | module.exports = { 92 | publishAmazon, 93 | } 94 | -------------------------------------------------------------------------------- /publisher/amazon/utils.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | const { log, logdev } = require('../../printers/index') 3 | 4 | const conf = { 5 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 6 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 7 | region: process.env.AWS_REGION, 8 | // may, may not be defined 9 | // sessionToken: process.env.AWS_SESSION_TOKEN || undefined, 10 | } 11 | 12 | if (process.env.AWS_SESSION_TOKEN != null && process.env.AWS_SESSION_TOKEN !== 'undefined') { 13 | conf.sessionToken = process.env.AWS_SESSION_TOKEN 14 | } 15 | 16 | AWS.config.update(conf) 17 | 18 | /** 19 | * @description Creates a new REGIONAL API in "region" named "apiName" 20 | * @param {string} apiName Name of API 21 | * @param {string} apiRegion 22 | * @returns {Promise<{apiId: string, apiUrl: string}>} Id and URL of the endpoint 23 | */ 24 | async function createApi(apiName, apiRegion) { 25 | const apigatewayv2 = new AWS.ApiGatewayV2({ 26 | apiVersion: '2018-11-29', 27 | region: apiRegion, 28 | }) 29 | 30 | // @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/ApiGatewayV2.html#createApi-property 31 | const createApiParams = { 32 | Name: apiName, 33 | ProtocolType: 'HTTP', 34 | // TODO regional is default, but how the to set to EDGE later on? 35 | // EndpointType: 'REGIONAL', // invalid field 36 | // Target: targetlambdaArn, // TODO 37 | CorsConfiguration: { 38 | AllowMethods: [ 39 | 'POST', 40 | 'GET', 41 | ], 42 | AllowOrigins: [ 43 | '*', 44 | ], 45 | }, 46 | } 47 | 48 | const createApiRes = await apigatewayv2.createApi(createApiParams).promise() 49 | 50 | const res = { 51 | apiId: createApiRes.ApiId, 52 | apiUrl: createApiRes.ApiEndpoint, 53 | } 54 | 55 | return res 56 | } 57 | 58 | /** 59 | * 60 | * @param {string} apiId 61 | * @param {string} apiRegion 62 | * @returns {Promise} 63 | */ 64 | async function deleteApi(apiId, apiRegion) { 65 | const apigatewayv2 = new AWS.ApiGatewayV2({ 66 | apiVersion: '2018-11-29', 67 | region: apiRegion, 68 | }) 69 | const deleteApiParams = { 70 | ApiId: apiId, 71 | } 72 | await apigatewayv2.deleteApi(deleteApiParams).promise() 73 | } 74 | 75 | /** 76 | * @description Returns the IntegrationId of the integration that matches 'name', or null. 77 | * If multiple exist, it returns the IntegrationId of the first one in the list. 78 | * @param {*} apiId 79 | * @param {*} apiRegion 80 | * @param {*} name 81 | */ 82 | async function getIntegrationId(apiId, apiRegion, name) { 83 | // On amazon, integration names are not unique, 84 | // but HF treats the as unique 85 | // and always reuses them 86 | const apigatewayv2 = new AWS.ApiGatewayV2({ 87 | apiVersion: '2018-11-29', 88 | region: apiRegion, 89 | }) 90 | 91 | const getParams = { 92 | ApiId: apiId, 93 | MaxResults: '9999', 94 | } 95 | 96 | // Get all integrations 97 | let res = await apigatewayv2.getIntegrations(getParams).promise() 98 | 99 | res = res.Items 100 | // Only integrations that match name 101 | .filter((el) => el.IntegrationUri.split(':')[-1] === name) 102 | 103 | // Just take the first one 104 | res = res[0] 105 | 106 | if (res && res.IntegrationId) { 107 | return res.IntegrationId 108 | } else { 109 | return null 110 | } 111 | } 112 | 113 | /** 114 | * @description Gets route Ids of GET and POST methods that match routePath 115 | * @param {*} apiId 116 | * @param {*} apiRegion 117 | * @param {*} routePath 118 | * @returns {[ { AuthorizationType: string, RouteId: string, RouteKey: string }]} 119 | */ 120 | async function getGETPOSTRoutesAt(apiId, apiRegion, routePath) { 121 | const apigatewayv2 = new AWS.ApiGatewayV2({ 122 | apiVersion: '2018-11-29', 123 | region: apiRegion, 124 | }) 125 | 126 | // TODO Amazon might return a paginated response here (?) 127 | // In that case with many routes, the route we look for may not be on first page 128 | const params = { 129 | ApiId: apiId, 130 | MaxResults: '9999', // string according to docs and it works... uuh? 131 | } 132 | 133 | const res = await apigatewayv2.getRoutes(params).promise() 134 | 135 | const matchingRoutes = res.Items 136 | .filter((item) => item.RouteKey && item.RouteKey.includes(routePath) === true) 137 | // only GET and POST ones 138 | .filter((item) => /GET|POST/.test(item.RouteKey) === true) 139 | 140 | return matchingRoutes 141 | } 142 | 143 | /** 144 | * Creates or update GET and POST routes with an integration. 145 | * If only one of GET or POST routes exist (user likely deleted one of them), 146 | * it updates that one. 147 | * Otherwise, it creates new both GET, POST routes. 148 | * Use createDefaultAutodeployStage too when creating, so that changes are made public 149 | * @param {*} apiId 150 | * @param {*} apiRegion 151 | * @param {*} routePath '/endpoint-1' for example 152 | * @param {*} integrationId 153 | */ 154 | async function setRoute(apiId, apiRegion, routePath, integrationId) { 155 | const apigatewayv2 = new AWS.ApiGatewayV2({ 156 | apiVersion: '2018-11-29', 157 | region: apiRegion, 158 | }) 159 | 160 | // Get route ids of GET & POST at that routePath 161 | const routes = await getGETPOSTRoutesAt(apiId, apiRegion, routePath) 162 | 163 | if (routes.length > 0) { 164 | /// //////////////////////////////////////////////// 165 | // Update routes (GET, POST) with integrationId 166 | /// //////////////////////////////////////////////// 167 | 168 | await Promise.all( 169 | routes.map(async (r) => { 170 | // skip if integration id is set correctly already 171 | if (r.Target && r.Target === `integrations/${integrationId}`) { 172 | return 173 | } 174 | 175 | const updateRouteParams = { 176 | ApiId: apiId, 177 | AuthorizationType: 'NONE', 178 | RouteId: r.RouteId, 179 | RouteKey: r.RouteKey, 180 | Target: `integrations/${integrationId}`, 181 | } 182 | 183 | try { 184 | const updateRes = await apigatewayv2.updateRoute(updateRouteParams).promise() 185 | } catch (e) { 186 | // This happens eg when there is only one of GET OR POST route (routes.length > 0) 187 | // Usually when the user deliberately deleted one of the 188 | // Ignore, as it's likely intented 189 | } 190 | }), 191 | ) 192 | } else { 193 | /// //////////////////////////////////////////////// 194 | // Create routes (GET, POST) with integrationId 195 | /// //////////////////////////////////////////////// 196 | 197 | // Create GET route 198 | const createGETRouteParams = { 199 | ApiId: apiId, 200 | AuthorizationType: 'NONE', 201 | RouteKey: `GET ${routePath}`, 202 | Target: `integrations/${integrationId}`, 203 | } 204 | 205 | const createGETRes = await apigatewayv2.createRoute(createGETRouteParams).promise() 206 | 207 | // Create POST route 208 | const createPOSTRouteParams = { ...createGETRouteParams } 209 | createPOSTRouteParams.RouteKey = `POST ${routePath}` 210 | 211 | const createPOSTRes = await apigatewayv2.createRoute(createPOSTRouteParams).promise() 212 | } 213 | } 214 | 215 | /** 216 | * Creates a (possibly duplicate) integration that can be attached. 217 | * Before creating, check getIntegrationId to avoid duplicates 218 | * @param {*} apiId 219 | * @param {*} apiRegion 220 | * @param {string} targetLambdaArn For instance a Lambda ARN 221 | * @returns {string} IntegrationId 222 | */ 223 | async function createIntegration(apiId, apiRegion, targetLambdaArn) { 224 | const apigatewayv2 = new AWS.ApiGatewayV2({ 225 | apiVersion: '2018-11-29', 226 | region: apiRegion, 227 | }) 228 | 229 | const params = { 230 | ApiId: apiId, 231 | IntegrationType: 'AWS_PROXY', 232 | IntegrationUri: targetLambdaArn, 233 | PayloadFormatVersion: '2.0', 234 | } 235 | 236 | const res = await apigatewayv2.createIntegration(params).promise() 237 | const integrationId = res.IntegrationId 238 | 239 | return integrationId 240 | } 241 | 242 | /** 243 | * @description Returns ApiId and ApiEndpoint of a regional API gateway API 244 | * with the name "apiName", in "apiRegion". 245 | * If multiple APIs exist with that name, it warns, and uses the first one in the received list. 246 | * If none exist, it returns null. 247 | * @param {string} apiName 248 | * @param {string} apiRegion 249 | * @returns {Promise<{apiId: string, apiUrl: string}>} Details of the API, or null 250 | */ 251 | async function getApiDetails(apiName, apiRegion) { 252 | // Check if API with that name exists 253 | // Follows Hyperform conv: same name implies identical, for lambdas, and api endpoints etc 254 | const apigatewayv2 = new AWS.ApiGatewayV2({ 255 | apiVersion: '2018-11-29', 256 | region: apiRegion, 257 | }) 258 | 259 | const getApisParams = { 260 | MaxResults: '9999', 261 | } 262 | 263 | const res = await apigatewayv2.getApis(getApisParams).promise() 264 | 265 | const matchingApis = res.Items.filter((item) => item.Name === apiName) 266 | if (matchingApis.length === 0) { 267 | // none exist 268 | return null 269 | } 270 | 271 | if (matchingApis.length >= 2) { 272 | log(`Multiple (${matchingApis.length}) APIs found with same name ${apiName}. Using first one`) 273 | } 274 | 275 | // just take first one 276 | // Hyperform convention is there's only one with any given name 277 | const apiDetails = { 278 | apiId: matchingApis[0].ApiId, 279 | apiUrl: matchingApis[0].ApiEndpoint, 280 | } 281 | 282 | return apiDetails 283 | } 284 | 285 | /** 286 | * 287 | * @param {*} apiId 288 | * @param {*} apiRegion 289 | * @throws If $default stage already exists 290 | */ 291 | async function createDefaultAutodeployStage(apiId, apiRegion) { 292 | const apigatewayv2 = new AWS.ApiGatewayV2({ 293 | apiVersion: '2018-11-29', 294 | region: apiRegion, 295 | }) 296 | const params = { 297 | ApiId: apiId, 298 | StageName: '$default', 299 | AutoDeploy: true, 300 | } 301 | 302 | const res = await apigatewayv2.createStage(params).promise() 303 | } 304 | 305 | /** 306 | * @description Add permssion to allow API gateway to invoke given Lambda 307 | * @param {string} lambdaName Name of Lambda 308 | * @param {string} region Region of Lambda 309 | * @returns {Promise} 310 | */ 311 | async function allowApiGatewayToInvokeLambda(lambdaName, region) { 312 | const lambda = new AWS.Lambda({ 313 | region: region, 314 | apiVersion: '2015-03-31', 315 | }) 316 | 317 | const addPermissionParams = { 318 | Action: 'lambda:InvokeFunction', 319 | FunctionName: lambdaName, 320 | Principal: 'apigateway.amazonaws.com', 321 | // TODO SourceArn https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Lambda.html#addPermission-property 322 | StatementId: `hf-stmnt-${lambdaName}`, 323 | } 324 | 325 | try { 326 | await lambda.addPermission(addPermissionParams).promise() 327 | } catch (e) { 328 | if (e.code === 'ResourceConflictException') { 329 | // API Gateway can already access that lambda (happens on all subsequent deploys), cool 330 | } else { 331 | logdev(`addpermission: some other error: ${e}`) 332 | throw e 333 | } 334 | } 335 | } 336 | 337 | module.exports = { 338 | createApi, 339 | _only_for_testing_deleteApi: deleteApi, 340 | allowApiGatewayToInvokeLambda, 341 | getApiDetails, 342 | createIntegration, 343 | setRoute, 344 | createDefaultAutodeployStage, 345 | } 346 | -------------------------------------------------------------------------------- /publisher/amazon/utils.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | const APIREGION = 'us-east-2' 3 | 4 | // other functions in publisher/amazon are pretty well covered by other tests 5 | describe('utils', () => { 6 | describe('createApi', () => { 7 | test('completes and returns apiId and apiUrl that is an URL', async () => { 8 | const uuidv4 = require('uuid').v4 9 | const { createApi } = require('./utils') 10 | const deleteApi = require('./utils')._only_for_testing_deleteApi 11 | 12 | const apiName = `jest-reserved-api-${uuidv4()}` 13 | 14 | let err 15 | let res 16 | try { 17 | res = await createApi(apiName, APIREGION) 18 | } catch (e) { 19 | console.log(e) 20 | err = e 21 | } 22 | 23 | // createApi did not throw 24 | expect(err).not.toBeDefined() 25 | expect(res).toBeDefined() 26 | expect(typeof res.apiUrl).toBe('string') 27 | expect(typeof res.apiId).toBe('string') 28 | 29 | // apiUrl is an URL 30 | const tryUrl = () => new URL(res.apiUrl) 31 | expect(tryUrl).not.toThrow() 32 | 33 | /// ////////////////////// 34 | // Clean up: Delete API 35 | 36 | await deleteApi(res.apiId, APIREGION) 37 | }, 10000) 38 | }) 39 | 40 | describe('setRoute', () => { 41 | test('completes on non-existing routes, existing routes', async () => { 42 | /// Create API 43 | const uuidv4 = require('uuid').v4 44 | const fetch = require('node-fetch') 45 | const { 46 | createApi, createIntegration, setRoute, createDefaultAutodeployStage, 47 | } = require('./utils') 48 | const deleteApi = require('./utils')._only_for_testing_deleteApi 49 | 50 | const apiName = `jest-reserved-api-${uuidv4()}` 51 | const routePath = '/endpoint_test' 52 | 53 | const { apiId, apiUrl } = await createApi(apiName, APIREGION) 54 | 55 | // Create Integration to some lambda 56 | // we won't call it, so doesn't matter really which lambda 57 | const targetLambdaArn = 'arn:aws:lambda:us-east-2:735406098573:function:endpoint_hello' 58 | const integrationId = await createIntegration(apiId, APIREGION, targetLambdaArn) 59 | 60 | // Always make changes public (importantly, things we will do with setRoute) 61 | await createDefaultAutodeployStage(apiId, APIREGION) 62 | 63 | let res 64 | let err 65 | try { 66 | res = await setRoute( 67 | apiId, 68 | APIREGION, 69 | routePath, 70 | integrationId, 71 | ) 72 | } catch (e) { 73 | err = e 74 | } 75 | 76 | // setRoute did not throw 77 | expect(err).not.toBeDefined() 78 | const fullurl = `${apiUrl}${routePath}` 79 | console.log(fullurl) 80 | // URL returns 200 81 | // On GET 82 | { 83 | const getres = await fetch(fullurl, { 84 | method: 'GET', 85 | }) 86 | const statusCode = `${getres.status}` 87 | console.log(statusCode) 88 | // GET route returns 2XX 89 | expect(/^2/.test(statusCode)).toBe(true) 90 | } 91 | // On POST 92 | { 93 | const getres = await fetch(fullurl, { 94 | method: 'POST', 95 | }) 96 | const statusCode = `${getres.status}` 97 | console.log(statusCode) 98 | // POST route returns 2XX 99 | expect(/^2/.test(statusCode)).toBe(true) 100 | } 101 | 102 | await deleteApi(apiId, APIREGION) 103 | }, 10000) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /response-collector/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | hyperform.json -------------------------------------------------------------------------------- /response-collector/index.js: -------------------------------------------------------------------------------- 1 | const aws = require('aws-sdk') 2 | const uuidv4 = require('uuid').v4 3 | 4 | /** 5 | * @description This serverless function gathers responses sent 6 | * by users answering $ hyperform survey 7 | */ 8 | 9 | async function collectSurveyResponse(event) { 10 | const s3 = new aws.S3() 11 | const filename = `${new Date().toDateString()}:${uuidv4()}.json` 12 | 13 | if (event == null) return 14 | 15 | const putParams = { 16 | Bucket: 'hyperform-survey-responses', 17 | Key: `cli-responses/${filename}`, 18 | Body: JSON.stringify(event, null, 2), 19 | } 20 | await s3.putObject(putParams).promise() 21 | } 22 | 23 | function getSurveyQuestion() { 24 | return { 25 | text: 'Some survey text', 26 | postUrl: 'https://mj9jbzlpxi.execute-api.us-east-2.amazonaws.com', 27 | } 28 | } 29 | 30 | module.exports = { 31 | endpoint_getSurveyQuestion: getSurveyQuestion, 32 | endpoint_collectSurveyResponse: collectSurveyResponse, 33 | } 34 | -------------------------------------------------------------------------------- /schemas/index.js: -------------------------------------------------------------------------------- 1 | const joi = require('joi') 2 | 3 | const amazonSchema = joi.object({ 4 | amazon: joi.object({ 5 | aws_access_key_id: joi.string().required(), 6 | aws_secret_access_key: joi.string().required(), 7 | aws_region: joi.string().required(), 8 | // allow if user enters it 9 | aws_session_token: joi.string().allow(''), 10 | }).required(), 11 | }) 12 | 13 | const googleSchema = joi.object({ 14 | google: joi.object({ 15 | gc_project: joi.string().required(), 16 | gc_region: joi.string().required(), 17 | }).required(), 18 | }) 19 | 20 | module.exports = { 21 | amazonSchema, 22 | googleSchema, 23 | } 24 | -------------------------------------------------------------------------------- /schemas/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | describe('schemas', () => { 3 | describe('amazon schema', () => { 4 | test('normal case', () => { 5 | const { amazonSchema } = require('./index') 6 | 7 | const input = { 8 | amazon: { 9 | aws_access_key_id: 'xx', 10 | aws_secret_access_key: 'xx', 11 | aws_region: 'xx', 12 | }, 13 | } 14 | 15 | const { error } = amazonSchema.validate(input) 16 | expect(error).not.toBeDefined() 17 | }) 18 | 19 | test('allows aws_session_token field', () => { 20 | const { amazonSchema } = require('./index') 21 | 22 | const input = { 23 | amazon: { 24 | aws_access_key_id: 'xx', 25 | aws_secret_access_key: 'xx', 26 | aws_region: 'xx', 27 | aws_session_token: 'xx', 28 | }, 29 | } 30 | 31 | const { error } = amazonSchema.validate(input) 32 | expect(error).not.toBeDefined() 33 | }) 34 | 35 | test('does not allow missing field in amazon', () => { 36 | const { amazonSchema } = require('./index') 37 | 38 | const input = { 39 | amazon: { 40 | aws_access_key_id: 'xx', 41 | aws_secret_access_key: 'xx', 42 | // Missing: aws_region: '', 43 | }, 44 | } 45 | 46 | const { error } = amazonSchema.validate(input) 47 | expect(error).toBeDefined() 48 | }) 49 | 50 | test('does not allow both providers', () => { 51 | const { amazonSchema } = require('./index') 52 | 53 | const input = { 54 | amazon: { 55 | aws_access_key_id: 'abc', 56 | aws_secret_access_key: 'abc', 57 | aws_region: 'abc', 58 | }, 59 | google: { 60 | gc_project: 'abc', 61 | gc_region: 'abc', 62 | }, 63 | } 64 | 65 | const { error } = amazonSchema.validate(input) 66 | expect(error).toBeDefined() 67 | }) 68 | 69 | test('does not allow no provider', () => { 70 | const { amazonSchema } = require('./index') 71 | 72 | const input = { 73 | // empty 74 | } 75 | 76 | const { error } = amazonSchema.validate(input) 77 | expect(error).toBeDefined() 78 | }) 79 | }) 80 | 81 | describe('google schema', () => { 82 | test('normal case', () => { 83 | const { googleSchema } = require('./index') 84 | 85 | const input = { 86 | google: { 87 | gc_project: 'abc', 88 | gc_region: 'abc', 89 | }, 90 | } 91 | 92 | const { error } = googleSchema.validate(input) 93 | expect(error).not.toBeDefined() 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /surveyor/index.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | 3 | // 0 to 1, with which to show survey 4 | const probability = 0.04 5 | 6 | const getSurveyUrl = 'https://era1vrrco0.execute-api.us-east-2.amazonaws.com' 7 | const postSurveyAnswerUrl = 'https://mj9jbzlpxi.execute-api.us-east-2.amazonaws.com' 8 | 9 | function maybeShowSurvey() { 10 | if ((new Date().getSeconds() / 60) < probability) { 11 | fetch(getSurveyUrl) 12 | .then((res) => res.json()) 13 | .then((res) => console.log(` 14 | 15 | ${res.text} 16 | You can type $ hf answer ... to answer :) 17 | `)) 18 | } 19 | } 20 | 21 | async function answerSurvey(text) { 22 | const currentSurvey = await fetch(getSurveyUrl) 23 | .then((res) => res.json()) 24 | 25 | await fetch(postSurveyAnswerUrl, { 26 | method: 'POST', 27 | body: JSON.stringify({ 28 | currentSurvey: currentSurvey, 29 | answer: text, 30 | date: new Date(), 31 | }), 32 | }) 33 | } 34 | 35 | module.exports = { 36 | maybeShowSurvey, 37 | answerSurvey, 38 | } 39 | -------------------------------------------------------------------------------- /template/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2021": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "airbnb-base" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": 12 13 | }, 14 | "rules": { 15 | "no-console": "off", 16 | "object-shorthand": "off", 17 | "no-restricted-syntax": "warn", 18 | "prefer-destructuring": "warn" 19 | } 20 | } -------------------------------------------------------------------------------- /template/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description The module appendix template. Never import this, 3 | * only copy-paste from here to transpiler/index.js 4 | * @param {*} moduleexp 5 | * @param {*} [exp] 6 | */ 7 | module.exports = () => { 8 | // START PASTE 9 | 10 | /** 11 | * Start Hyperform wrapper 12 | * It provides some simple usability features 13 | * Amazon: 14 | * Google: 15 | * - Send pre-flight headers 16 | * - console.error on error 17 | */ 18 | global.alreadyWrappedNames = []; 19 | 20 | function wrapExs(me, platform) { 21 | const newmoduleexports = { ...me }; 22 | const expkeys = Object.keys(me); 23 | for (let i = 0; i < expkeys.length; i += 1) { 24 | const expkey = expkeys[i]; 25 | const userfunc = newmoduleexports[expkey]; 26 | // it should be idempotent 27 | // TODO fix code so this doesn't happen 28 | if (global.alreadyWrappedNames.includes(expkey)) { 29 | continue; 30 | } 31 | global.alreadyWrappedNames.push(expkey); 32 | let wrappedfunc; 33 | if (platform === 'amazon') { 34 | wrappedfunc = async function handler(event, context, callback) { 35 | /// //////////////////////////////// 36 | // Invoke user function /////// 37 | /// //////////////////////////////// 38 | 39 | const res = await userfunc(event, context, callback); 40 | context.succeed(res); 41 | // throwing will call context.fail automatically 42 | }; 43 | } 44 | if (platform === 'google') { 45 | wrappedfunc = async function handler(req, resp, ...rest) { 46 | // allow to be called from anywhere (also localhost) 47 | // resp.header('Content-Type', 'application/json'); 48 | resp.header('Access-Control-Allow-Origin', '*'); 49 | resp.header('Access-Control-Allow-Headers', '*'); 50 | resp.header('Access-Control-Allow-Methods', '*'); 51 | resp.header('Access-Control-Max-Age', 30); 52 | 53 | // respond to CORS preflight requests 54 | if (req.method === 'OPTIONS') { 55 | resp.status(204).send(''); 56 | } else { 57 | // Invoke user function 58 | // (user must .json or .send himself) 59 | try { 60 | await userfunc(req, resp, ...rest); 61 | } catch (e) { 62 | console.error(e); 63 | resp.status(500).send(''); // TODO generate URL to logs (similar to GH) 64 | } 65 | } 66 | }; 67 | } 68 | newmoduleexports[expkey] = wrappedfunc; 69 | } 70 | return newmoduleexports; 71 | } 72 | const curr = { ...exports, ...module.exports }; 73 | const isInAmazon = !!(process.env.LAMBDA_TASK_ROOT || process.env.AWS_EXECUTION_ENV); 74 | const isInGoogle = (/google/.test(process.env._) === true); 75 | if (isInAmazon === true) { 76 | return wrapExs(curr, 'amazon'); 77 | } 78 | if (isInGoogle === true) { 79 | return wrapExs(curr, 'google'); 80 | } 81 | return curr; // Export unchanged (local, fallback) 82 | 83 | // END PASTE 84 | }; 85 | -------------------------------------------------------------------------------- /transpiler/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Transpiles Javascript code so that its exported functions can run on Amazon, Google 3 | * @param {string} bundleCode Bundled Javascript code. 4 | * Output of the 'bundle' function in bundle/index.js 5 | * @returns {string} The code, transpiled for providers 6 | */ 7 | function transpile(bundleCode) { 8 | const appendix = ` 9 | 10 | ;module.exports = (() => { 11 | 12 | // START PASTE 13 | 14 | /** 15 | * Start Hyperform wrapper 16 | * It provides some simple usability features 17 | * Amazon: 18 | * Google: 19 | * - Send pre-flight headers 20 | * - console.error on error 21 | */ 22 | global.alreadyWrappedNames = []; 23 | 24 | function wrapExs(me, platform) { 25 | const newmoduleexports = { ...me }; 26 | const expkeys = Object.keys(me); 27 | for (let i = 0; i < expkeys.length; i += 1) { 28 | const expkey = expkeys[i]; 29 | const userfunc = newmoduleexports[expkey]; 30 | // it should be idempotent 31 | // TODO fix code so this doesn't happen 32 | if (global.alreadyWrappedNames.includes(expkey)) { 33 | continue; 34 | } 35 | global.alreadyWrappedNames.push(expkey); 36 | let wrappedfunc; 37 | if (platform === 'amazon') { 38 | wrappedfunc = async function handler(event, context, callback) { 39 | /// //////////////////////////////// 40 | // Invoke user function /////// 41 | /// //////////////////////////////// 42 | 43 | const res = await userfunc(event, context, callback); 44 | context.succeed(res); 45 | // throwing will call context.fail automatically 46 | }; 47 | } 48 | if (platform === 'google') { 49 | wrappedfunc = async function handler(req, resp, ...rest) { 50 | // allow to be called from anywhere (also localhost) 51 | // resp.header('Content-Type', 'application/json'); 52 | resp.header('Access-Control-Allow-Origin', '*'); 53 | resp.header('Access-Control-Allow-Headers', '*'); 54 | resp.header('Access-Control-Allow-Methods', '*'); 55 | resp.header('Access-Control-Max-Age', 30); 56 | 57 | // respond to CORS preflight requests 58 | if (req.method === 'OPTIONS') { 59 | resp.status(204).send(''); 60 | } else { 61 | // Invoke user function 62 | // (user must .json or .send himself) 63 | try { 64 | await userfunc(req, resp, ...rest); 65 | } catch (e) { 66 | console.error(e); 67 | resp.status(500).send(''); // TODO generate URL to logs (similar to GH) 68 | } 69 | } 70 | }; 71 | } 72 | newmoduleexports[expkey] = wrappedfunc; 73 | } 74 | return newmoduleexports; 75 | } 76 | const curr = { ...exports, ...module.exports }; 77 | const isInAmazon = !!(process.env.LAMBDA_TASK_ROOT || process.env.AWS_EXECUTION_ENV); 78 | const isInGoogle = (/google/.test(process.env._) === true); 79 | if (isInAmazon === true) { 80 | return wrapExs(curr, 'amazon'); 81 | } 82 | if (isInGoogle === true) { 83 | return wrapExs(curr, 'google'); 84 | } 85 | return curr; // Export unchanged (local, fallback) 86 | 87 | 88 | })(); 89 | ` 90 | 91 | const transpiledCode = bundleCode + appendix 92 | 93 | return transpiledCode 94 | } 95 | 96 | module.exports = { 97 | transpile, 98 | } 99 | -------------------------------------------------------------------------------- /uploader/amazon/index.js: -------------------------------------------------------------------------------- 1 | // const AWS = require('aws-sdk') 2 | 3 | // /** 4 | // * 5 | // * @param {*} localpath 6 | // * @param {*} bucket 7 | // * @param {*} key 8 | // */ 9 | // async function uploadAmazon(localpath, bucket, key) { 10 | // const s3 = new AWS.S3() 11 | // const fsp = require('fs').promises 12 | 13 | // const contents = await fsp.readFile(localpath) 14 | // const uploadParams = { 15 | // Bucket: bucket, 16 | // Key: key, 17 | // Body: contents, 18 | // } 19 | // await s3.upload(uploadParams).promise() 20 | 21 | // const s3path = `s3://${bucket}/${key}` 22 | // return s3path 23 | // } 24 | 25 | // module.exports = { 26 | // uploadAmazon, 27 | // } 28 | 29 | -------------------------------------------------------------------------------- /uploader/amazon/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | const S3BUCKET = 'jak-bridge-typical' 4 | 5 | describe('uploader', () => { 6 | describe('amazon', () => { 7 | describe('uploadAmazon', () => { 8 | test('uploads simple text file, subsequent get returns that file', async () => { 9 | // const AWS = require('aws-sdk') 10 | // const s3 = new AWS.S3() 11 | // const uuidv4 = require('uuid').v4 12 | 13 | // const { uploadAmazon } = require('./index') 14 | // const key = `${uuidv4()}` 15 | // const filecontents = key 16 | // const filecontentsbuffer = Buffer.from(key) 17 | 18 | // let res 19 | // let err 20 | // try { 21 | // res = await uploadAmazon(filecontentsbuffer, S3BUCKET, key) 22 | // } catch (e) { 23 | // console.log(e) 24 | // err = e 25 | // } 26 | 27 | // // it didn't throw 28 | // expect(err).not.toBeDefined() 29 | // // it returned s3:// ... 30 | // expect(typeof res).toBe('string') 31 | // expect(res).toBe(`s3://${S3BUCKET}/${key}`) 32 | 33 | // // getting file immediately after must have same contents 34 | // const getParams = { 35 | // Bucket: S3BUCKET, 36 | // Key: key, 37 | // } 38 | // const getRes = await s3.getObject(getParams).promise() 39 | // const gottenFilecontents = getRes.Body.toString('utf-8') 40 | 41 | // expect(gottenFilecontents).toBe(filecontents) 42 | }) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /uploader/google/index.js: -------------------------------------------------------------------------------- 1 | // const { Storage } = require('@google-cloud/storage') 2 | 3 | // const gcloudstorage = new Storage() 4 | 5 | // async function uploadGoogle(localpath, bucket, key) { 6 | // const res = await gcloudstorage.bucket(bucket).upload(localpath, { 7 | // gzip: true, 8 | // destination: key, 9 | // metadata: { 10 | // // Docs: (If the contents will change, use cacheControl: 'no-cache') 11 | // // @see https://github.com/googleapis/nodejs-storage/blob/master/samples/uploadFile.js 12 | // cacheControl: 'no-cache', 13 | // }, 14 | // }) 15 | 16 | // const gsPath = `gs://${bucket}/${key}` 17 | // return gsPath 18 | // } 19 | 20 | // module.exports = { 21 | // uploadGoogle, 22 | // } 23 | -------------------------------------------------------------------------------- /zipper/google/index.js: -------------------------------------------------------------------------------- 1 | const _zipdir = require('zip-dir') 2 | const fsp = require('fs').promises 3 | const path = require('path') 4 | const os = require('os') 5 | /** 6 | * 7 | * @param {string} dir 8 | * @param {string[]} except names of directories or files that will not be included 9 | * (usually ["node_modules", ".git", ".github"]) Uses substring check. 10 | * @returns {string} outpath of the zip 11 | * 12 | */ 13 | async function zipDir(dir, except) { 14 | // create tmp dir 15 | const outdir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zipDir-zipped-')) 16 | const outpath = path.join(outdir, 'deploypackage.zip') 17 | 18 | // The second argument of https://www.npmjs.com/package/zip-dir 19 | // Function that is called on every file / dir to determine if it'll be included in the zip 20 | const filterFunc = (p, stat) => { 21 | for (let i = 0; i < except.length; i += 1) { 22 | if (p.includes(except[i])) { 23 | console.log(`Excluding ${p}`) 24 | return false 25 | } 26 | } 27 | return true 28 | } 29 | 30 | const res = await new Promise((resolve, rej) => { 31 | _zipdir(dir, { 32 | saveTo: outpath, 33 | filter: filterFunc, 34 | 35 | }, 36 | (err, r) => { 37 | if (err) { 38 | rej(err) 39 | } else { 40 | resolve(outpath) // resolve with the outpath 41 | } 42 | }) 43 | }) 44 | 45 | return res 46 | } 47 | 48 | module.exports = { 49 | zipDir, 50 | } 51 | -------------------------------------------------------------------------------- /zipper/index.js: -------------------------------------------------------------------------------- 1 | const fsp = require('fs').promises 2 | const fs = require('fs') 3 | const os = require('os') 4 | const path = require('path') 5 | 6 | const { Readable } = require('stream') 7 | 8 | const yazl = require('yazl') 9 | /** 10 | * @description Creates a .zip that contains given filecontents, within given filenames. All at the zip root 11 | * @param {{}} filesobj For instance { 'file.txt': 'abc' } 12 | * @returns {Promise} Path to the created .zip 13 | */ 14 | async function zip(filesobj) { 15 | const uid = `${Math.ceil(Math.random() * 10000)}` 16 | const zipfile = new yazl.ZipFile() 17 | 18 | console.time(`zip-${uid}`) 19 | // create tmp dir 20 | const outdir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zipped-')) 21 | const outpath = path.join(outdir, 'deploypackage.zip') 22 | 23 | zipfile.outputStream.pipe(fs.createWriteStream(outpath)) 24 | 25 | // filesobj is like { 'file.txt': 'abc', 'file2.txt': '123' } 26 | // for each such destination file,... 27 | const fnames = Object.keys(filesobj) 28 | for (let i = 0; i < fnames.length; i += 1) { 29 | const fname = fnames[i] 30 | const fcontent = filesobj[fname] 31 | 32 | // set up stream 33 | const s = new Readable(); 34 | s._read = () => {}; 35 | s.push(fcontent); 36 | s.push(null); 37 | 38 | // In zip, set last-modified header to 01-01-2020 39 | // this way, rezipping identical files is deterministic (gives the same codesha256) 40 | // that way we can skip uploading zips that haven't changed 41 | const options = { 42 | mtime: new Date(1577836), 43 | } 44 | 45 | zipfile.addReadStream(s, fname, options); // place code in index.js inside zip 46 | // console.log(`created ${fname} in zip`) 47 | } 48 | 49 | zipfile.end() 50 | 51 | console.timeEnd(`zip-${uid}`) 52 | return outpath 53 | } 54 | 55 | module.exports = { 56 | zip, 57 | } 58 | -------------------------------------------------------------------------------- /zipper/index.test.js: -------------------------------------------------------------------------------- 1 | describe('zipper', () => { 2 | test('completes with multiple files and returns valid path', async () => { 3 | const path = require('path') 4 | const fs = require('fs') 5 | const { zip } = require('./index') 6 | 7 | const inp = { 8 | 'fileWithinZip.txt': 'abc', 9 | } 10 | 11 | let err 12 | let res 13 | try { 14 | res = await zip(inp) 15 | } catch (e) { 16 | console.log(e) 17 | err = e 18 | } 19 | 20 | expect(err).not.toBeDefined() 21 | 22 | // expect it's a path 23 | // https://stackoverflow.com/a/38974272 24 | expect(res === path.basename(res)).toBe(false) 25 | 26 | // expect it ends with '.zip' 27 | expect(path.extname(res)).toBe('.zip') 28 | 29 | // expect we can get details about it 30 | let statErr 31 | try { 32 | fs.statSync(res) 33 | } catch (e) { 34 | statErr = e 35 | } 36 | 37 | expect(statErr).not.toBeDefined() 38 | }) 39 | }) 40 | --------------------------------------------------------------------------------