├── .gitignore ├── README.md ├── dist ├── bin │ └── command.js └── lib │ ├── build │ ├── build.js │ └── buildController.js │ ├── deploy │ ├── deploy.js │ └── deployController.js │ ├── serve │ ├── serve.js │ ├── serveController.js │ └── serveController.test.js │ └── types.js ├── package-lock.json ├── package.json ├── src ├── bin │ └── command.ts └── lib │ ├── build │ ├── build.ts │ └── buildController.ts │ ├── deploy │ ├── deploy.ts │ └── deployController.ts │ ├── serve │ ├── serve.ts │ ├── serveController.test.ts │ └── serveController.ts │ └── types.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | 90 | # VS Code 91 | .vscode/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Airfn 2 | 3 | Airfn is a CLI tool that enables users to easily and quickly serve and deploy their AWS lambda functions to AWS with their existing development environment, increasing developer productivity by abstracting configuration process 4 | 5 | ## How it works 6 | 7 | Users (developers) are able to use our CLI to serve functions locally, build functions to 8 | optimize speed and performance, and deploy those functions as serverless Lambdas by 9 | creating an account on the Airfn Web App and doing the following: 10 | 11 | * User installs our Node.js CLI globally by running `npm install -g airfn` in terminal. 12 | 13 | * User initializes a configuration file by entering `air init` in terminal 14 | within project directory. 15 | 16 | * User serves functions locally as Lambdas by entering `air serve`. 17 | 18 | > CLI spins up an Express server and serves the user’s functions, using the 19 | names of the functions as the names of the API endpoints. User can now 20 | locally test her Lambdas by sending requests to the endpoints. 21 | 22 | * User builds functions for deployment by entering `air build`. 23 | 24 | > CLI processes user's functions to transpile functions source code and any 25 | imported Node modules to her chosen Node.js version via Babel. 26 | 27 | * User deploys functions to AWS by entering `air deploy`. 28 | 29 | > CLI gets function source code of user's functions that will be used to deploy as Lambdas to AWS and return endpoints. 30 | 31 |

32 | 33 |

34 | -------------------------------------------------------------------------------- /dist/bin/command.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict"; 3 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | var __importDefault = (this && this.__importDefault) || function (mod) { 12 | return (mod && mod.__esModule) ? mod : { "default": mod }; 13 | }; 14 | Object.defineProperty(exports, "__esModule", { value: true }); 15 | const os_1 = __importDefault(require("os")); 16 | const fs_1 = __importDefault(require("fs")); 17 | const path_1 = __importDefault(require("path")); 18 | const commander_1 = __importDefault(require("commander")); 19 | const inquirer_1 = __importDefault(require("inquirer")); 20 | const ora_1 = __importDefault(require("ora")); 21 | const chalk_1 = __importDefault(require("chalk")); 22 | const axios_1 = __importDefault(require("axios")); 23 | const serve_1 = __importDefault(require("../lib/serve/serve")); 24 | const build_1 = require("../lib/build/build"); 25 | const deploy_1 = __importDefault(require("../lib/deploy/deploy")); 26 | // TODO allow custom configuration of API Gateway subdomain 27 | const ROOT_CONFIG_FILENAME = 'config.json'; 28 | const ROOT_CONFIG_DIRNAME = '.airfn'; 29 | const BASE_API_GATEWAY_ENDPOINT = 'lambda9.cloud'; 30 | const AUTH_ENDPOINT = 'https://test.lambda9.cloud/cli/cliauth'; 31 | const SPINNER_TIMEOUT = 1000; 32 | const JSONpackage = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '..', '..', 'package.json'))); 33 | commander_1.default.version(JSONpackage.version); 34 | commander_1.default 35 | .command('init') 36 | .description('Initialize configuration for serving, building, and deploying lambda functions') 37 | .action(() => __awaiter(this, void 0, void 0, function* () { 38 | const airfnConfig = {}; 39 | const cwdName = path_1.default.parse(process.cwd()).name; 40 | console.log(`\n👤 Please login with your username and password\nYou can sign up for an account at https://airfn.io/signup\n`); 41 | // TODO: Implement actual auth 42 | yield inquirer_1.default 43 | .prompt([ 44 | { 45 | name: 'username', 46 | message: 'Username:', 47 | }, 48 | ]) 49 | .then((answers) => __awaiter(this, void 0, void 0, function* () { 50 | const username = answers.username; 51 | airfnConfig.user = answers.username; 52 | yield inquirer_1.default 53 | .prompt([ 54 | { 55 | name: 'password', 56 | type: 'password', 57 | message: 'Password:', 58 | }, 59 | ]) 60 | .then((answers) => __awaiter(this, void 0, void 0, function* () { 61 | const password = answers.password; 62 | const credentials = { 63 | username, 64 | password 65 | }; 66 | yield axios_1.default.post(AUTH_ENDPOINT, credentials).then((response) => { 67 | const homedir = os_1.default.homedir(); 68 | const rootConfigDir = path_1.default.join(homedir, ROOT_CONFIG_DIRNAME); 69 | const rootConfigPath = path_1.default.join(rootConfigDir, ROOT_CONFIG_FILENAME); 70 | const rootConfig = { 71 | clientId: response.data 72 | }; 73 | if (!fs_1.default.existsSync(rootConfigDir)) { 74 | fs_1.default.mkdir(rootConfigDir, (err) => { 75 | if (err) 76 | console.log(`😓 Failed to build config: ${err}`); 77 | }); 78 | } 79 | fs_1.default.writeFile(rootConfigPath, JSON.stringify(rootConfig), err => { 80 | if (err) 81 | console.log(`😓 Failed to build config: ${err}`); 82 | }); 83 | }).catch((err) => { 84 | console.log(`❌ Wrong username/password combination.\n Retry by running 'air init' again`); 85 | process.exit(); 86 | }); 87 | })); 88 | })); 89 | yield inquirer_1.default 90 | .prompt([ 91 | { 92 | name: 'project', 93 | message: 'Enter project name for your lambda functions:', 94 | default: cwdName, 95 | }, 96 | ]) 97 | .then((answers) => { 98 | airfnConfig.project = answers.project; 99 | }); 100 | yield inquirer_1.default 101 | .prompt([ 102 | { 103 | name: 'functionsSrc', 104 | message: 'In which directory are your lambda functions?', 105 | default: 'src/functions', 106 | }, 107 | ]) 108 | .then((answers) => __awaiter(this, void 0, void 0, function* () { 109 | const functionsSrc = answers.functionsSrc; 110 | airfnConfig.functionsSrc = functionsSrc; 111 | if (!fs_1.default.existsSync(answers.functionsSrc)) { 112 | yield inquirer_1.default 113 | .prompt([ 114 | { 115 | type: 'confirm', 116 | name: 'createSrcDir', 117 | message: `There's no directory at ${answers.functionsSrc}. Would you like to create one now?`, 118 | }, 119 | ]) 120 | .then((answers) => { 121 | if (answers.createSrcDir === true && functionsSrc) { 122 | fs_1.default.mkdirSync(path_1.default.join(process.cwd(), functionsSrc), { 123 | recursive: true, 124 | }); 125 | } 126 | }); 127 | } 128 | })); 129 | yield inquirer_1.default 130 | .prompt([ 131 | { 132 | name: 'functionsOutput', 133 | message: 'In which directory would you like your built lambda functions? (a root level directory is recommended)', 134 | default: '/functions', 135 | }, 136 | ]) 137 | .then((answers) => { 138 | airfnConfig.functionsOutput = answers.functionsOutput; 139 | }); 140 | yield inquirer_1.default 141 | .prompt([ 142 | { 143 | type: 'list', 144 | name: 'nodeRuntime', 145 | message: 'Which NodeJS runtime will your lambda functions use?', 146 | choices: ['10.15', '8.10'], 147 | }, 148 | ]) 149 | .then((answers) => { 150 | airfnConfig.nodeRuntime = answers.nodeRuntime; 151 | }); 152 | yield inquirer_1.default 153 | .prompt([ 154 | { 155 | name: 'functionsOutput', 156 | message: 'On which local port do you want to serve your lambda functions?', 157 | default: '9000', 158 | }, 159 | ]) 160 | .then((answers) => { 161 | airfnConfig.port = Number(answers.functionsOutput); 162 | }); 163 | fs_1.default.writeFile('airfn.json', JSON.stringify(airfnConfig), err => { 164 | if (err) 165 | console.log(`😓 Failed to build config: ${err}`); 166 | console.log('\n💾 Your Airfn config has been saved!'); 167 | }); 168 | })); 169 | commander_1.default 170 | .command('serve') 171 | .description('Serve and watch functions') 172 | .action(() => { 173 | getUserAccessKey(); 174 | const airfnConfig = getUserLambdaConfig(); 175 | const spinner = ora_1.default('☁️ Airfn: Serving functions...').start(); 176 | setTimeout(() => { 177 | const useStatic = Boolean(commander_1.default.static); 178 | let server; 179 | const startServer = () => { 180 | server = serve_1.default(airfnConfig.functionsOutput, airfnConfig.port || 9000, useStatic, Number(commander_1.default.timeout) || 10); 181 | }; 182 | if (useStatic) { 183 | startServer(); 184 | return; 185 | } 186 | const { config: userWebpackConfig, babelrc: useBabelrc = true } = commander_1.default; 187 | build_1.watch(airfnConfig.functionsSrc, airfnConfig.functionsOutput, airfnConfig.nodeRuntime, { userWebpackConfig, useBabelrc }, (err, stats) => { 188 | if (err) { 189 | console.error(err); 190 | return; 191 | } 192 | console.log(chalk_1.default.hex('#24c4f4')(stats.toString())); 193 | spinner.stop(); 194 | if (!server) { 195 | startServer(); 196 | console.log('\n✅ Done serving!'); 197 | } 198 | else { 199 | console.log('\n🔨 Done rebuilding!'); 200 | } 201 | stats.compilation.chunks.forEach((chunk) => { 202 | server.clearCache(chunk.name || chunk.id().toString()); 203 | }); 204 | }); 205 | }, SPINNER_TIMEOUT); 206 | }); 207 | commander_1.default 208 | .command('build') 209 | .description('Build functions') 210 | .action(() => { 211 | getUserAccessKey(); 212 | const spinner = ora_1.default('☁️ Airfn: Building functions...').start(); 213 | setTimeout(() => { 214 | const airfnConfig = getUserLambdaConfig(); 215 | spinner.color = 'green'; 216 | const { config: userWebpackConfig, babelrc: useBabelrc = true } = commander_1.default; 217 | build_1.run(airfnConfig.functionsSrc, airfnConfig.functionsOutput, airfnConfig.nodeRuntime, { 218 | userWebpackConfig, 219 | useBabelrc, 220 | }) 221 | .then((stats) => { 222 | console.log(chalk_1.default.hex('#f496f4')(stats.toString())); 223 | spinner.stop(); 224 | console.log('\n✅ Done building!'); 225 | }) 226 | .catch((err) => { 227 | console.error(err); 228 | process.exit(1); 229 | }); 230 | }, SPINNER_TIMEOUT); 231 | }); 232 | commander_1.default 233 | .command('deploy') 234 | .description('Deploys functions to AWS') 235 | .action(() => { 236 | const accessKey = getUserAccessKey(); 237 | const airfnConfig = getUserLambdaConfig(); 238 | const spinner = ora_1.default('☁️ Airfn: Deploying functions...').start(); 239 | setTimeout(() => { 240 | const { config: userWebpackConfig, babelrc: useBabelrc = true } = commander_1.default; 241 | // TODO: Handle already built functions 242 | build_1.run(airfnConfig.functionsSrc, airfnConfig.functionsOutput, airfnConfig.nodeRuntime, { userWebpackConfig, useBabelrc }) 243 | .then((stats) => { 244 | console.log(chalk_1.default.hex('#f496f4')(stats.toString())); 245 | deploy_1.default(airfnConfig.user, accessKey, airfnConfig.project, airfnConfig.functionsSrc, airfnConfig.functionsOutput) 246 | .then((result) => { 247 | // TODO: Give lambda endpoints to user 248 | spinner.stop(); 249 | console.log(`\n🚀 Successfully deployed! ${result.data}`); 250 | console.log(`\n🔗 Lambda endpoints:`); 251 | result.endpoints.forEach((endpoint) => { 252 | console.log(`https://${airfnConfig.project}.${BASE_API_GATEWAY_ENDPOINT}/${endpoint}`); 253 | }); 254 | }) 255 | .catch((err) => { 256 | spinner.stop(); 257 | console.log(`😓 Failed to deploy: ${err}`); 258 | }); 259 | }) 260 | .catch((err) => { 261 | console.error(err); 262 | process.exit(1); 263 | }); 264 | }, SPINNER_TIMEOUT); 265 | }); 266 | commander_1.default 267 | .command('logout') 268 | .description('Log out of Airfn CLI') 269 | .action(() => { 270 | const { configFound, configDir } = rootConfigExists(); 271 | if (configFound) { 272 | try { 273 | removeDir(configDir); 274 | console.log('Logged out of Airfn CLI'); 275 | process.exit(0); 276 | } 277 | catch (err) { 278 | console.error(`Failed to log out`); 279 | } 280 | } 281 | else { 282 | console.log(`Already logged out`); 283 | process.exit(1); 284 | } 285 | }); 286 | commander_1.default.on('command:*', function () { 287 | console.error(`\n❌ "${commander_1.default.args.join(' ')}" command not found!`); 288 | process.exit(1); 289 | }); 290 | commander_1.default.parse(process.argv); 291 | const NO_COMMAND_SPECIFIED = commander_1.default.args.length === 0; 292 | if (NO_COMMAND_SPECIFIED) { 293 | commander_1.default.help(); 294 | } 295 | function getUserAccessKey() { 296 | const { configFound, configPath } = rootConfigExists(); 297 | if (configFound) { 298 | try { 299 | const rootConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8')); 300 | return rootConfig.clientId; 301 | } 302 | catch (err) { 303 | console.log(`❌ Error reading config`); 304 | } 305 | } 306 | else { 307 | console.log(`❗️ Please login first by running 'air init'`); 308 | process.exit(1); 309 | } 310 | } 311 | function rootConfigExists() { 312 | const homedir = os_1.default.homedir(); 313 | const rootConfigDir = path_1.default.join(homedir, ROOT_CONFIG_DIRNAME); 314 | const rootConfigPath = path_1.default.join(rootConfigDir, ROOT_CONFIG_FILENAME); 315 | const configFound = fs_1.default.existsSync(rootConfigPath); 316 | const configProps = { 317 | configFound: configFound, 318 | configDir: rootConfigDir, 319 | configPath: rootConfigPath 320 | }; 321 | return configProps; 322 | } 323 | function getUserLambdaConfig() { 324 | try { 325 | const config = JSON.parse(fs_1.default.readFileSync(path_1.default.join(process.cwd(), 'airfn.json'), 'utf-8')); 326 | return config; 327 | } 328 | catch (err) { 329 | console.log(`❌ No Airfn config found. Did you first run 'l9 init'?`); 330 | process.exit(1); 331 | } 332 | } 333 | function removeDir(dir) { 334 | const list = fs_1.default.readdirSync(dir); 335 | for (let i = 0; i < list.length; i++) { 336 | const filename = path_1.default.join(dir, list[i]); 337 | const stat = fs_1.default.statSync(filename); 338 | if (stat.isDirectory()) { 339 | removeDir(filename); 340 | } 341 | else { 342 | fs_1.default.unlinkSync(filename); 343 | } 344 | } 345 | fs_1.default.rmdirSync(dir); 346 | } 347 | -------------------------------------------------------------------------------- /dist/lib/build/build.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const path_1 = __importDefault(require("path")); 7 | const fs_1 = __importDefault(require("fs")); 8 | const webpack_1 = __importDefault(require("webpack")); 9 | const buildController_1 = __importDefault(require("./buildController")); 10 | const createWebpack = buildController_1.default(path_1.default, fs_1.default, webpack_1.default); 11 | function run(srcDir, outputDir, runtime, additionalConfig) { 12 | return new Promise((resolve, reject) => { 13 | webpack_1.default(createWebpack(srcDir, outputDir, runtime, additionalConfig), (err, stats) => { 14 | if (err) { 15 | return reject(err); 16 | } 17 | resolve(stats); 18 | }); 19 | }); 20 | } 21 | exports.run = run; 22 | function watch(srcDir, outputDir, runtime, additionalConfig, cb) { 23 | var compiler = webpack_1.default(createWebpack(srcDir, outputDir, runtime, additionalConfig)); 24 | compiler.watch(createWebpack(srcDir, outputDir, runtime, additionalConfig), cb); 25 | } 26 | exports.watch = watch; 27 | -------------------------------------------------------------------------------- /dist/lib/build/buildController.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const testFilePattern = '\\.(test|spec)\\.?'; 4 | exports.default = (path, fs, webpack) => (srcDir, outputDir, runtime, { userWebpackConfig, useBabelrc } = {}) => { 5 | const babelOpts = { 6 | cacheDirectory: true, 7 | presets: [ 8 | [ 9 | require.resolve('@babel/preset-env'), 10 | { targets: { node: getBabelTarget({}, runtime) } }, 11 | ], 12 | ], 13 | plugins: [ 14 | require.resolve('@babel/plugin-proposal-class-properties'), 15 | require.resolve('@babel/plugin-transform-object-assign'), 16 | require.resolve('@babel/plugin-proposal-object-rest-spread'), 17 | ], 18 | }; 19 | const functionsDir = outputDir; 20 | const functionsPath = path.join(process.cwd(), functionsDir); 21 | const dirPath = path.join(process.cwd(), srcDir); 22 | const defineEnv = {}; 23 | const nodeEnv = process.env.NODE_ENV || 'production'; 24 | const webpackMode = ['production', 'development'].includes(nodeEnv) 25 | ? nodeEnv 26 | : 'none'; 27 | const webpackConfig = { 28 | mode: webpackMode, 29 | resolve: { 30 | extensions: ['.wasm', '.mjs', '.js', '.json', '.ts'], 31 | mainFields: ['module', 'main'], 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.(m?js|ts)?$/, 37 | exclude: new RegExp(`(node_modules|bower_components|${testFilePattern})`), 38 | use: { 39 | loader: require.resolve('babel-loader'), 40 | options: Object.assign({}, babelOpts, { babelrc: useBabelrc }), 41 | }, 42 | }, 43 | ], 44 | }, 45 | context: dirPath, 46 | entry: {}, 47 | target: 'node', 48 | plugins: [ 49 | new webpack.IgnorePlugin(/vertx/), 50 | new webpack.DefinePlugin(defineEnv), 51 | ], 52 | output: { 53 | path: functionsPath, 54 | filename: '[name].js', 55 | libraryTarget: 'commonjs', 56 | }, 57 | optimization: { 58 | nodeEnv, 59 | }, 60 | bail: true, 61 | devtool: false, 62 | }; 63 | fs.readdirSync(dirPath).forEach((file) => { 64 | if (file.match(/\.(m?js|ts)$/)) { 65 | var name = file.replace(/\.(m?js|ts)$/, ''); 66 | if (!name.match(new RegExp(testFilePattern))) { 67 | webpackConfig.entry[name] = './' + file; 68 | } 69 | } 70 | }); 71 | return webpackConfig; 72 | }; 73 | function getBabelTarget(envConfig, runtime) { 74 | const key = 'AWS_LAMBDA_JS_RUNTIME'; 75 | // If NodeJS runtime specified during l9 init 76 | if (runtime) 77 | return runtime; 78 | // Otherwise use user webpack settings if exists 79 | const runtimes = ['nodejs8.15.0', 'nodejs6.10.3']; 80 | const current = envConfig[key] || process.env[key] || 'nodejs8.15.0'; 81 | const unknown = runtimes.indexOf(current) === -1; 82 | return unknown ? '8.15.0' : current.replace(/^nodejs/, ''); 83 | } 84 | -------------------------------------------------------------------------------- /dist/lib/deploy/deploy.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const path_1 = require("path"); 7 | const fs_1 = require("fs"); 8 | const axios_1 = __importDefault(require("axios")); 9 | const js_yaml_1 = require("js-yaml"); 10 | const deployController_1 = require("./deployController"); 11 | const DEPLOY_ENDPOINT = 'http://api.lambda9.cloud/lambda/deploy'; 12 | const S3_CREATE_LAMBDA_ENDPOINT = 'https://cli.lambda9.cloud/createbucket'; 13 | const CREATE_DOMAIN_ENDPOINT = 'https://cli.lambda9.cloud/deploydomain'; 14 | const SAVE_FUNCTIONS_ENDPOINT = 'https://cli.lambda9.cloud/savefunctions'; 15 | const LOGS_SUBSCRIPTION_ENDPOINT = 'https://cli.lambda9.cloud/subscribelogs'; 16 | const LOG_GROUP_PREFIX = '/aws/lambda/'; 17 | const BASE_DOMAIN = 'lambda9.cloud'; 18 | exports.default = (user, accessKey, project, functionsSrc, functionsOutput) => { 19 | return new Promise((resolve, reject) => { 20 | getFunctionsSourceCode(); 21 | const deployArtifacts = deployController_1.createDeployArtifacts(functionsOutput, path_1.join, { 22 | readFileSync: fs_1.readFileSync, 23 | readdirSync: fs_1.readdirSync, 24 | }, js_yaml_1.safeDump); 25 | deployController_1.createUserS3Bucket(S3_CREATE_LAMBDA_ENDPOINT, user, axios_1.default.post) 26 | .then((response) => { 27 | const requestData = Object.assign({ user, 28 | project }, deployArtifacts); 29 | axios_1.default({ 30 | method: 'post', 31 | url: DEPLOY_ENDPOINT, 32 | data: requestData, 33 | maxContentLength: Infinity, 34 | }) 35 | .then((response) => { 36 | const lambdaData = { 37 | endpoints, 38 | data: response.data, 39 | }; 40 | createDomain(project, project); 41 | saveFunctions(functionsSourceCode, project, accessKey); 42 | subscribeToLogs(logGroupPrefixes); 43 | return resolve(lambdaData); 44 | }) 45 | .catch(err => { 46 | return reject(err); 47 | }); 48 | }) 49 | .catch((err) => { 50 | console.log('😓 Error making S3 buckets for lambda functions'); 51 | }); 52 | const logGroupPrefixes = createLogGroupPrefixes(deployArtifacts.funcArr, project); 53 | const functionsSourceCode = getFunctionsSourceCode(); 54 | const endpoints = createEndpoints(deployArtifacts.funcArr); 55 | }); 56 | function createDomain(subdomainPrefix, stackName) { 57 | const data = { 58 | domainName: `${subdomainPrefix}.${BASE_DOMAIN}`, 59 | stackName 60 | }; 61 | axios_1.default({ 62 | method: "post", 63 | url: CREATE_DOMAIN_ENDPOINT, 64 | data 65 | }) 66 | .then((response) => { 67 | console.log(`\n${response.data}`); 68 | }) 69 | .catch(err => { 70 | console.log('😓 Error creating lambda subdomain'); 71 | }); 72 | } 73 | function saveFunctions(functions, projectName, accessKey) { 74 | const data = { 75 | functions, 76 | projectName, 77 | accessKey 78 | }; 79 | axios_1.default({ 80 | method: "post", 81 | url: SAVE_FUNCTIONS_ENDPOINT, 82 | data, 83 | maxContentLength: Infinity 84 | }) 85 | .then((response) => { 86 | console.log('Saved lambda functions'); 87 | }) 88 | .catch(err => { 89 | }); 90 | } 91 | function createLogGroupPrefixes(functions, projectName) { 92 | return functions.map((funcObj) => { 93 | const funcName = path_1.parse(funcObj.funcName).name; 94 | return `${LOG_GROUP_PREFIX}${projectName}-${funcName}`; 95 | }); 96 | } 97 | function subscribeToLogs(logGroupsPrefixes) { 98 | const data = { 99 | logGroupsPrefixes 100 | }; 101 | axios_1.default({ 102 | method: "post", 103 | url: LOGS_SUBSCRIPTION_ENDPOINT, 104 | data 105 | }) 106 | .then((response) => { 107 | }) 108 | .catch(err => { 109 | }); 110 | } 111 | function createEndpoints(functions) { 112 | return functions.map((funcObj) => { 113 | return path_1.parse(funcObj.funcName).name.toLowerCase(); 114 | }); 115 | } 116 | function getFunctionsSourceCode() { 117 | const funcArr = []; 118 | fs_1.readdirSync(path_1.join(process.cwd(), String(functionsSrc))).forEach((file) => { 119 | const data = fs_1.readFileSync(path_1.join(process.cwd(), `${functionsSrc}/${file}`), 'utf8'); 120 | const funcObj = { 121 | funcName: file, 122 | funcDef: data, 123 | }; 124 | funcArr.push(funcObj); 125 | }); 126 | return funcArr; 127 | } 128 | }; 129 | -------------------------------------------------------------------------------- /dist/lib/deploy/deployController.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const YAML_CONFIG_TEMPLATE = { 4 | AWSTemplateFormatVersion: '2010-09-09', 5 | Transform: 'AWS::Serverless-2016-10-31', 6 | Description: 'Deployed with Airfn CLI.', 7 | Outputs: { 8 | ApiGatewayId: { 9 | Value: { 10 | Ref: "ServerlessRestApi" 11 | } 12 | } 13 | }, 14 | Resources: {} 15 | }; 16 | function createDeployArtifacts(functionsOutput, join, fs, safeDump) { 17 | const funcArr = []; 18 | const yamlConfig = YAML_CONFIG_TEMPLATE; 19 | fs.readdirSync(join(process.cwd(), functionsOutput)).forEach((file) => { 20 | createFunctionResource(file, yamlConfig); 21 | const data = fs.readFileSync(join(process.cwd(), `${functionsOutput}/${file}`), 'utf8'); 22 | const funcObj = { 23 | funcName: file, 24 | funcDef: data, 25 | }; 26 | funcArr.push(funcObj); 27 | }); 28 | return { 29 | yaml: safeDump(yamlConfig, { noCompatMode: true, noRefs: true }), 30 | funcArr, 31 | }; 32 | } 33 | exports.createDeployArtifacts = createDeployArtifacts; 34 | ; 35 | function createUserS3Bucket(endpoint, user, post) { 36 | const data = { 37 | user 38 | }; 39 | return new Promise((resolve, reject) => { 40 | post(endpoint, data).then((response) => resolve(response.data)).catch((err) => reject(err)); 41 | }); 42 | } 43 | exports.createUserS3Bucket = createUserS3Bucket; 44 | ; 45 | function createFunctionResource(fileName, yamlConfig) { 46 | fileName = fileName.replace(/\.[^/.]+$/, ''); 47 | const funcTemplate = { 48 | Type: 'AWS::Serverless::Function', 49 | Properties: { 50 | Handler: `${fileName}.handler`, 51 | Runtime: 'nodejs8.10', 52 | CodeUri: '.', 53 | Description: 'A function deployed with Airfn CLI', 54 | MemorySize: 512, 55 | Timeout: 10, 56 | Events: { 57 | Api1: { 58 | Type: 'Api', 59 | Properties: { 60 | Path: `/${fileName}`.toLowerCase(), 61 | Method: 'ANY', 62 | }, 63 | }, 64 | }, 65 | }, 66 | }; 67 | yamlConfig.Resources[fileName] = funcTemplate; 68 | } 69 | -------------------------------------------------------------------------------- /dist/lib/serve/serve.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const express_1 = __importDefault(require("express")); 7 | const serveController_1 = __importDefault(require("./serveController")); 8 | const path_1 = __importDefault(require("path")); 9 | const querystring_1 = __importDefault(require("querystring")); 10 | const body_parser_1 = __importDefault(require("body-parser")); 11 | const createHandler = serveController_1.default(path_1.default, querystring_1.default); 12 | const chalk_1 = __importDefault(require("chalk")); 13 | function listen(src, port, useStatic, timeout) { 14 | const app = express_1.default(); 15 | app.use(body_parser_1.default.json()); 16 | app.use(body_parser_1.default.urlencoded({ extended: true })); 17 | app.get('/favicon.ico', function (req, res) { 18 | return res.status(204).end(); 19 | }); 20 | app.all('*', createHandler(src, false, 10), (req, res) => { 21 | return res.end(); 22 | }); 23 | const server = app.listen(port, () => { 24 | console.log(chalk_1.default.green(`Example app listening on port ${port}!`)); 25 | }); 26 | app.get('/favicon.ico', function (req, res) { 27 | res.status(204).end(); 28 | }); 29 | return { 30 | clearCache: (chunk) => { 31 | const module = path_1.default.join(process.cwd(), String(src), chunk); 32 | delete require.cache[require.resolve(module)]; 33 | } 34 | }; 35 | } 36 | exports.default = listen; 37 | -------------------------------------------------------------------------------- /dist/lib/serve/serveController.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | // TODO: Proper TypeScript types for modules 4 | exports.default = (path, queryString) => (dir, useStatic, timeout) => { 5 | return function (req, res, next) { 6 | const fn = req.path.split('/').filter(name => name)[0]; 7 | const joinModPath = path.join(process.cwd(), dir, fn); 8 | const handler = require(joinModPath); 9 | const lambdaReq = { 10 | path: req.path, 11 | httpMethod: req.method, 12 | queryStringParameters: queryString.parse(req.url.split(/\?(.+)/)[1]), 13 | headers: req.headers, 14 | body: req.body, 15 | }; 16 | const callback = createCallback(res); 17 | const promise = handler.handler(lambdaReq, null, callback); 18 | Promise.all([promisifyHandler(promise, callback)]) // TODO: Implement promise with timeout 19 | .then(() => { 20 | return next(); 21 | }) 22 | .catch(err => { 23 | throw err; 24 | }); 25 | }; 26 | }; 27 | function createCallback(res) { 28 | return function callback(err, lambdaRes) { 29 | if (err) 30 | return err; // TODO: Proper error handling 31 | res.statusCode = lambdaRes.statusCode; 32 | for (let key in lambdaRes.headers) { 33 | res.setHeader(key, lambdaRes.headers[key]); 34 | } 35 | if (lambdaRes.body) { 36 | res.write(lambdaRes.body); 37 | } 38 | }; 39 | } 40 | function promisifyHandler(promise, callback) { 41 | if (!promise || 42 | typeof promise.then !== 'function' || 43 | typeof callback !== 'function') 44 | return; 45 | return promise 46 | .then((data) => { 47 | callback(null, data); 48 | }) 49 | .catch((err) => { 50 | callback(err, null); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /dist/lib/serve/serveController.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | return new (P || (P = Promise))(function (resolve, reject) { 4 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 5 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 6 | function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } 7 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 8 | }); 9 | }; 10 | const { createHandler, createCallback, promiseHandler, } = require('./serveController')(); 11 | /* Sets up test mocks for Express request and response objects */ 12 | function setup() { 13 | const req = { 14 | body: {}, 15 | path: '', 16 | url: '', 17 | }; 18 | const res = { 19 | locals: { 20 | error: {}, 21 | lambdaResponse: {}, 22 | }, 23 | statusCode: null, 24 | body: '', 25 | headers: {}, 26 | }; 27 | const next = jest.fn(); 28 | Object.assign(res, { 29 | status: jest.fn(function status() { 30 | return this; 31 | }.bind(res)), 32 | json: jest.fn(function json() { 33 | return this; 34 | }.bind(res)), 35 | send: jest.fn(function send() { 36 | return this; 37 | }.bind(res)), 38 | }); 39 | return { req, res, next }; 40 | } 41 | describe('createHandler', () => { 42 | test('Should have proper error object in res.locals if requiring function module fails', () => __awaiter(this, void 0, void 0, function* () { 43 | const { req, res, next } = setup(); 44 | req.path = '/helloasync'; 45 | const dir = '/functions'; 46 | const useStatic = false; 47 | const timeout = 5; 48 | const errorObj = { 49 | code: 500, 50 | type: 'Server', 51 | message: 'Loading function failed', 52 | }; 53 | yield createHandler(dir, useStatic, timeout)(req, res, () => { 54 | expect(res.locals.error).toEqual(errorObj); 55 | }); 56 | })); 57 | test('Should have a proper error object in res.locals if lambda is not invoked before timeout', () => __awaiter(this, void 0, void 0, function* () { 58 | const { req, res, next } = setup(); 59 | req.path = '/helloasync'; 60 | const dir = '/functions'; 61 | const useStatic = false; 62 | const timeout = 5; 63 | const errorObj = { 64 | code: 400, 65 | type: 'Client', 66 | message: 'Failed to invoke function before timeout', 67 | }; 68 | yield createHandler(dir, useStatic, timeout)(req, res, () => { 69 | expect(res.locals.error).not.toEqual(errorObj); 70 | }); 71 | })); 72 | test('Should have a proper lambdaResponse object in res.locals', () => __awaiter(this, void 0, void 0, function* () { 73 | const { req, res, next } = setup(); 74 | req.path = '/helloasync'; 75 | req.url = 'http://localhost:9000/helloasync'; 76 | const dir = '/functions'; 77 | const useStatic = false; 78 | const timeout = 5; 79 | const lambdaResponse = [ 80 | { 81 | statusCode: 200, 82 | body: 'Hello, World', 83 | }, 84 | ]; 85 | yield createHandler(dir, useStatic, timeout)(req, res, () => { 86 | expect(res.locals.lambdaResponse).not.toEqual(lambdaResponse); 87 | }); 88 | })); 89 | }); 90 | describe('createCallback', () => { 91 | const res = { 92 | headers: { 93 | 'Access-Control-Allow-Origin': '*', 94 | 'Access-Control-Allow-Headers': 'Content-Type', 95 | }, 96 | statusCode: 200, 97 | body: 'Why should you never trust a pig with a ' + 98 | "secret? Because it's bound to squeal.", 99 | }; 100 | test('Should return a function', () => __awaiter(this, void 0, void 0, function* () { 101 | const res = {}; 102 | yield expect(typeof createCallback(res)).toEqual('function'); 103 | })); 104 | test('Returned callback should be able to handle errors', () => __awaiter(this, void 0, void 0, function* () { 105 | const res = { 106 | headers: { 107 | 'Access-Control-Allow-Origin': '*', 108 | 'Access-Control-Allow-Headers': 'Content-Type', 109 | }, 110 | statusCode: 200, 111 | body: 'Why should you never trust a pig with a ' + 112 | "secret? Because it's bound to squeal.", 113 | }; 114 | const returnFunc = yield createCallback(res); 115 | expect(returnFunc(new Error('this is an error'), null)).toBeInstanceOf(Error); 116 | })); 117 | test('Callback should set proper response object with status code, headers, and body', () => __awaiter(this, void 0, void 0, function* () { 118 | const { res } = setup(); 119 | const lambdaResponse = { 120 | statusCode: 200, 121 | headers: { 122 | header1: 'Facebook', 123 | header2: 'Google', 124 | }, 125 | body: 'Hello, World', 126 | }; 127 | yield createCallback(res)(null, lambdaResponse); 128 | yield expect(res).toMatchObject(lambdaResponse); 129 | })); 130 | }); 131 | describe('promiseHandler', () => { }); 132 | -------------------------------------------------------------------------------- /dist/lib/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Airfn", 3 | "version": "0.0.1", 4 | "description": "Serve, build, and deploy AWS Lambda functions", 5 | "main": "index.js", 6 | "dependencies": { 7 | "aws-kcl": "^2.0.0", 8 | "axios": "^0.19.0", 9 | "body-parser": "^1.19.0", 10 | "commander": "^2.20.0", 11 | "express": "^4.17.1", 12 | "inquirer": "^6.4.1", 13 | "js-yaml": "^3.13.1", 14 | "ora": "^3.4.0", 15 | "querystring": "^0.2.0" 16 | }, 17 | "devDependencies": { 18 | "@babel/plugin-proposal-class-properties": "^7.4.4", 19 | "@babel/plugin-proposal-object-rest-spread": "^7.4.4", 20 | "@babel/plugin-transform-object-assign": "^7.2.0", 21 | "@babel/preset-env": "^7.4.5", 22 | "@types/body-parser": "^1.17.0", 23 | "@types/chalk": "^2.2.0", 24 | "@types/express": "^4.17.0", 25 | "@types/inquirer": "^6.0.3", 26 | "@types/jest": "^24.0.15", 27 | "@types/js-yaml": "^3.12.1", 28 | "@types/node": "^12.0.10", 29 | "@types/webpack": "^4.4.34", 30 | "babel-loader": "^8.0.6", 31 | "jest": "^24.8.0", 32 | "ts-jest": "^24.0.2", 33 | "ts-node": "^8.3.0", 34 | "typescript": "^3.5.2", 35 | "webpack": "^4.35.2" 36 | }, 37 | "bin": { 38 | "airfn": "./dist/bin/command.js", 39 | "air": "./dist/bin/command.js" 40 | }, 41 | "scripts": { 42 | "start": "npm run dev", 43 | "dev": "nodemon --exec 'ts-node' src/bin/command.ts", 44 | "prod": "tsc && node ./build/app.js", 45 | "tsc": "tsc", 46 | "test": "jest" 47 | }, 48 | "keywords": [ 49 | "lambda", 50 | "serverless", 51 | "faas", 52 | "aws" 53 | ], 54 | "author": "Bruce Wong, Esther Lee, Jayvee Aspa, Jun Lee", 55 | "license": "MIT" 56 | } 57 | -------------------------------------------------------------------------------- /src/bin/command.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import os from 'os'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import program from 'commander'; 7 | import inquirer from 'inquirer'; 8 | import ora from 'ora'; 9 | import chalk from 'chalk'; 10 | import axios, { AxiosResponse } from 'axios'; 11 | import listen from '../lib/serve/serve'; 12 | import { run, watch } from '../lib/build/build'; 13 | import deploy from '../lib/deploy/deploy'; 14 | import { projConfig } from '../lib/types'; 15 | import { config } from 'rxjs'; 16 | 17 | // TODO allow custom configuration of API Gateway subdomain 18 | const ROOT_CONFIG_FILENAME = 'config.json'; 19 | const ROOT_CONFIG_DIRNAME = '.airfn'; 20 | const BASE_API_GATEWAY_ENDPOINT = 'lambda9.cloud'; 21 | const AUTH_ENDPOINT = 'https://test.lambda9.cloud/cli/cliauth'; 22 | const SPINNER_TIMEOUT = 1000; 23 | declare global { 24 | interface JSON { 25 | parse(text: Buffer, reviver?: (key: any, value: any) => any): any; 26 | } 27 | } 28 | 29 | const JSONpackage = JSON.parse( 30 | fs.readFileSync(path.join(__dirname, '..', '..', 'package.json')) 31 | ); 32 | 33 | program.version(JSONpackage.version); 34 | 35 | program 36 | .command('init') 37 | .description( 38 | 'Initialize configuration for serving, building, and deploying lambda functions' 39 | ) 40 | .action(async () => { 41 | const airfnConfig: projConfig = {}; 42 | const cwdName: string = path.parse(process.cwd()).name; 43 | 44 | console.log(`\n👤 Please login with your username and password\nYou can sign up for an account at https://airfn.io/signup\n`); 45 | 46 | // TODO: Implement actual auth 47 | await inquirer 48 | .prompt([ 49 | { 50 | name: 'username', 51 | message: 'Username:', 52 | }, 53 | ]) 54 | .then(async (answers: any) => { 55 | const username = answers.username; 56 | airfnConfig.user = answers.username; 57 | await inquirer 58 | .prompt([ 59 | { 60 | name: 'password', 61 | type: 'password', 62 | message: 'Password:', 63 | }, 64 | ]) 65 | .then(async (answers: any) => { 66 | const password = answers.password; 67 | const credentials = { 68 | username, 69 | password 70 | } 71 | await axios.post(AUTH_ENDPOINT, credentials).then((response: AxiosResponse) => { 72 | const homedir = os.homedir(); 73 | const rootConfigDir = path.join(homedir, ROOT_CONFIG_DIRNAME); 74 | const rootConfigPath = path.join(rootConfigDir, ROOT_CONFIG_FILENAME); 75 | 76 | const rootConfig = { 77 | clientId: response.data 78 | }; 79 | if (!fs.existsSync(rootConfigDir)){ 80 | fs.mkdir(rootConfigDir, (err) => { 81 | if (err) console.log(`😓 Failed to build config: ${err}`); 82 | }); } 83 | fs.writeFile(rootConfigPath, JSON.stringify(rootConfig), err => { 84 | if (err) console.log(`😓 Failed to build config: ${err}`); 85 | }); 86 | }).catch((err: Error) => { 87 | console.log(`❌ Wrong username/password combination.\n Retry by running 'air init' again`); 88 | process.exit() 89 | }) 90 | }); 91 | }); 92 | 93 | await inquirer 94 | .prompt([ 95 | { 96 | name: 'project', 97 | message: 'Enter project name for your lambda functions:', 98 | default: cwdName, 99 | }, 100 | ]) 101 | .then((answers: any) => { 102 | airfnConfig.project = answers.project; 103 | }); 104 | 105 | await inquirer 106 | .prompt([ 107 | { 108 | name: 'functionsSrc', 109 | message: 'In which directory are your lambda functions?', 110 | default: 'src/functions', 111 | }, 112 | ]) 113 | .then(async (answers: any) => { 114 | const functionsSrc = answers.functionsSrc; 115 | airfnConfig.functionsSrc = functionsSrc; 116 | if (!fs.existsSync(answers.functionsSrc)) { 117 | await inquirer 118 | .prompt([ 119 | { 120 | type: 'confirm', 121 | name: 'createSrcDir', 122 | message: `There's no directory at ${ 123 | answers.functionsSrc 124 | }. Would you like to create one now?`, 125 | }, 126 | ]) 127 | .then((answers: any) => { 128 | if (answers.createSrcDir === true && functionsSrc) { 129 | fs.mkdirSync(path.join(process.cwd(), functionsSrc!), { 130 | recursive: true, 131 | }); 132 | } 133 | }); 134 | } 135 | }); 136 | 137 | await inquirer 138 | .prompt([ 139 | { 140 | name: 'functionsOutput', 141 | message: 142 | 'In which directory would you like your built lambda functions? (a root level directory is recommended)', 143 | default: '/functions', 144 | }, 145 | ]) 146 | .then((answers: any) => { 147 | airfnConfig.functionsOutput = answers.functionsOutput; 148 | }); 149 | 150 | await inquirer 151 | .prompt([ 152 | { 153 | type: 'list', 154 | name: 'nodeRuntime', 155 | message: 'Which NodeJS runtime will your lambda functions use?', 156 | choices: ['10.15', '8.10'], 157 | }, 158 | ]) 159 | .then((answers: any) => { 160 | airfnConfig.nodeRuntime = answers.nodeRuntime; 161 | }); 162 | 163 | await inquirer 164 | .prompt([ 165 | { 166 | name: 'functionsOutput', 167 | message: 168 | 'On which local port do you want to serve your lambda functions?', 169 | default: '9000', 170 | }, 171 | ]) 172 | .then((answers: any) => { 173 | airfnConfig.port = Number(answers.functionsOutput); 174 | }); 175 | 176 | fs.writeFile('airfn.json', JSON.stringify(airfnConfig), err => { 177 | if (err) console.log(`😓 Failed to build config: ${err}`); 178 | console.log('\n💾 Your Airfn config has been saved!'); 179 | }); 180 | }); 181 | 182 | program 183 | .command('serve') 184 | .description('Serve and watch functions') 185 | .action(() => { 186 | getUserAccessKey(); 187 | const airfnConfig = getUserLambdaConfig()!; 188 | const spinner = ora('☁️ Airfn: Serving functions...').start(); 189 | setTimeout(() => { 190 | const useStatic = Boolean(program.static); 191 | let server: any; 192 | const startServer = () => { 193 | server = listen( 194 | airfnConfig.functionsOutput, 195 | airfnConfig.port || 9000, 196 | useStatic, 197 | Number(program.timeout) || 10 198 | ); 199 | }; 200 | if (useStatic) { 201 | startServer(); 202 | return; 203 | } 204 | const { config: userWebpackConfig, babelrc: useBabelrc = true } = program; 205 | watch( 206 | airfnConfig.functionsSrc, 207 | airfnConfig.functionsOutput, 208 | airfnConfig.nodeRuntime, 209 | { userWebpackConfig, useBabelrc }, 210 | (err: Error, stats: any) => { 211 | if (err) { 212 | console.error(err); 213 | return; 214 | } 215 | console.log(chalk.hex('#24c4f4')(stats.toString())); 216 | spinner.stop(); 217 | if (!server) { 218 | startServer(); 219 | console.log('\n✅ Done serving!'); 220 | } else { 221 | console.log('\n🔨 Done rebuilding!'); 222 | } 223 | 224 | stats.compilation.chunks.forEach((chunk: any) => { 225 | server.clearCache(chunk.name || chunk.id().toString()); 226 | }); 227 | } 228 | ); 229 | }, SPINNER_TIMEOUT); 230 | }); 231 | 232 | program 233 | .command('build') 234 | .description('Build functions') 235 | .action(() => { 236 | getUserAccessKey(); 237 | const spinner = ora('☁️ Airfn: Building functions...').start(); 238 | setTimeout(() => { 239 | const airfnConfig = getUserLambdaConfig()!; 240 | spinner.color = 'green'; 241 | const { config: userWebpackConfig, babelrc: useBabelrc = true } = program; 242 | run( 243 | airfnConfig.functionsSrc, 244 | airfnConfig.functionsOutput, 245 | airfnConfig.nodeRuntime, 246 | { 247 | userWebpackConfig, 248 | useBabelrc, 249 | } 250 | ) 251 | .then((stats: any) => { 252 | console.log(chalk.hex('#f496f4')(stats.toString())); 253 | spinner.stop(); 254 | console.log('\n✅ Done building!'); 255 | }) 256 | .catch((err: Error) => { 257 | console.error(err); 258 | process.exit(1); 259 | }); 260 | }, SPINNER_TIMEOUT); 261 | }); 262 | 263 | program 264 | .command('deploy') 265 | .description('Deploys functions to AWS') 266 | .action(() => { 267 | const accessKey = getUserAccessKey(); 268 | const airfnConfig = getUserLambdaConfig()!; 269 | const spinner = ora('☁️ Airfn: Deploying functions...').start(); 270 | setTimeout(() => { 271 | const { config: userWebpackConfig, babelrc: useBabelrc = true } = program; 272 | // TODO: Handle already built functions 273 | run( 274 | airfnConfig.functionsSrc, 275 | airfnConfig.functionsOutput, 276 | airfnConfig.nodeRuntime, 277 | { userWebpackConfig, useBabelrc } 278 | ) 279 | .then((stats: any) => { 280 | console.log(chalk.hex('#f496f4')(stats.toString())); 281 | deploy( 282 | airfnConfig.user, 283 | accessKey, 284 | airfnConfig.project, 285 | airfnConfig.functionsSrc, 286 | airfnConfig.functionsOutput 287 | ) 288 | .then((result: any) => { 289 | // TODO: Give lambda endpoints to user 290 | spinner.stop(); 291 | console.log(`\n🚀 Successfully deployed! ${result.data}`); 292 | console.log(`\n🔗 Lambda endpoints:`); 293 | result.endpoints.forEach((endpoint: string) => { 294 | console.log( 295 | `https://${airfnConfig.project}.${BASE_API_GATEWAY_ENDPOINT}/${endpoint}` 296 | ); 297 | }); 298 | }) 299 | .catch((err: Error) => { 300 | spinner.stop(); 301 | console.log(`😓 Failed to deploy: ${err}`); 302 | }); 303 | }) 304 | .catch((err: Error) => { 305 | console.error(err); 306 | process.exit(1); 307 | }); 308 | }, SPINNER_TIMEOUT); 309 | }); 310 | 311 | program 312 | .command('logout') 313 | .description('Log out of Airfn CLI') 314 | .action(() => { 315 | const { configFound, configDir } = rootConfigExists(); 316 | if (configFound) { 317 | try { 318 | removeDir(configDir); 319 | console.log('Logged out of Airfn CLI'); 320 | process.exit(0); 321 | } catch(err) { 322 | console.error(`Failed to log out`); 323 | } 324 | } else { 325 | console.log(`Already logged out`); 326 | process.exit(1); 327 | } 328 | }); 329 | 330 | program.on('command:*', function() { 331 | console.error(`\n❌ "${program.args.join(' ')}" command not found!`); 332 | process.exit(1); 333 | }); 334 | 335 | program.parse(process.argv); 336 | 337 | const NO_COMMAND_SPECIFIED = program.args.length === 0; 338 | 339 | if (NO_COMMAND_SPECIFIED) { 340 | program.help(); 341 | } 342 | 343 | function getUserAccessKey() { 344 | const { configFound, configPath } = rootConfigExists(); 345 | if (configFound) { 346 | try { 347 | const rootConfig = JSON.parse( 348 | fs.readFileSync(configPath, 'utf-8') 349 | ); 350 | return rootConfig.clientId; 351 | } catch (err) { 352 | console.log(`❌ Error reading config`) 353 | } 354 | 355 | } else { 356 | console.log(`❗️ Please login first by running 'air init'`); 357 | process.exit(1); 358 | } 359 | } 360 | 361 | function rootConfigExists() { 362 | const homedir = os.homedir(); 363 | const rootConfigDir = path.join(homedir, ROOT_CONFIG_DIRNAME); 364 | const rootConfigPath = path.join(rootConfigDir, ROOT_CONFIG_FILENAME); 365 | const configFound = fs.existsSync(rootConfigPath); 366 | const configProps = { 367 | configFound: configFound, 368 | configDir: rootConfigDir, 369 | configPath: rootConfigPath 370 | }; 371 | return configProps; 372 | } 373 | 374 | function getUserLambdaConfig() { 375 | try { 376 | const config: projConfig = JSON.parse( 377 | fs.readFileSync(path.join(process.cwd(), 'airfn.json'), 'utf-8') 378 | ); 379 | return config; 380 | } catch (err) { 381 | console.log(`❌ No Airfn config found. Did you first run 'l9 init'?`); 382 | process.exit(1); 383 | } 384 | } 385 | 386 | function removeDir(dir: string) { 387 | const list = fs.readdirSync(dir); 388 | for(let i = 0; i < list.length; i++) { 389 | const filename = path.join(dir, list[i]); 390 | const stat = fs.statSync(filename); 391 | if (stat.isDirectory()) { 392 | removeDir(filename); 393 | } else { 394 | fs.unlinkSync(filename); 395 | } 396 | } 397 | fs.rmdirSync(dir); 398 | } 399 | 400 | -------------------------------------------------------------------------------- /src/lib/build/build.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import webpack from 'webpack'; 4 | import build from './buildController'; 5 | const createWebpack = build(path, fs, webpack); 6 | 7 | function run( 8 | srcDir: string | void, 9 | outputDir: string | void, 10 | runtime: string | void, 11 | additionalConfig: object 12 | ) { 13 | return new Promise((resolve, reject) => { 14 | webpack( 15 | createWebpack(srcDir, outputDir, runtime, additionalConfig), 16 | (err, stats) => { 17 | if (err) { 18 | return reject(err); 19 | } 20 | resolve(stats); 21 | } 22 | ); 23 | }); 24 | } 25 | 26 | function watch( 27 | srcDir: string | void, 28 | outputDir: string | void, 29 | runtime: string | void, 30 | additionalConfig: object, 31 | cb: webpack.ICompiler.Handler 32 | ) { 33 | var compiler = webpack( 34 | createWebpack(srcDir, outputDir, runtime, additionalConfig) 35 | ); 36 | compiler.watch( 37 | createWebpack(srcDir, outputDir, runtime, additionalConfig), 38 | cb 39 | ); 40 | } 41 | 42 | export { run, watch }; 43 | -------------------------------------------------------------------------------- /src/lib/build/buildController.ts: -------------------------------------------------------------------------------- 1 | import { DefinePlugin, IgnorePlugin } from 'webpack'; 2 | 3 | const testFilePattern = '\\.(test|spec)\\.?'; 4 | 5 | interface babelOptsObj { 6 | cacheDirectory: boolean; 7 | presets: any[]; 8 | plugins: any[]; 9 | } 10 | 11 | interface webpackPlugins { 12 | DefinePlugin: typeof DefinePlugin; 13 | IgnorePlugin: typeof IgnorePlugin; 14 | } 15 | 16 | export default ( 17 | path: { join: Function }, 18 | fs: { readdirSync: Function }, 19 | webpack: webpackPlugins 20 | ) => ( 21 | srcDir: string | void, 22 | outputDir: string | void, 23 | runtime: string | void, 24 | { userWebpackConfig, useBabelrc }: any = {} 25 | ) => { 26 | const babelOpts: babelOptsObj = { 27 | cacheDirectory: true, 28 | presets: [ 29 | [ 30 | require.resolve('@babel/preset-env'), 31 | { targets: { node: getBabelTarget({}, runtime) } }, 32 | ], 33 | ], 34 | plugins: [ 35 | require.resolve('@babel/plugin-proposal-class-properties'), 36 | require.resolve('@babel/plugin-transform-object-assign'), 37 | require.resolve('@babel/plugin-proposal-object-rest-spread'), 38 | ], 39 | }; 40 | 41 | const functionsDir = outputDir; 42 | const functionsPath = path.join(process.cwd(), functionsDir); 43 | const dirPath = path.join(process.cwd(), srcDir); 44 | 45 | const defineEnv = {}; 46 | const nodeEnv = process.env.NODE_ENV || 'production'; 47 | 48 | const webpackMode = ['production', 'development'].includes(nodeEnv) 49 | ? nodeEnv 50 | : 'none'; 51 | 52 | const webpackConfig: any = { 53 | mode: webpackMode, 54 | resolve: { 55 | extensions: ['.wasm', '.mjs', '.js', '.json', '.ts'], 56 | mainFields: ['module', 'main'], 57 | }, 58 | module: { 59 | rules: [ 60 | { 61 | test: /\.(m?js|ts)?$/, 62 | exclude: new RegExp( 63 | `(node_modules|bower_components|${testFilePattern})` 64 | ), 65 | use: { 66 | loader: require.resolve('babel-loader'), 67 | options: { ...babelOpts, babelrc: useBabelrc }, 68 | }, 69 | }, 70 | ], 71 | }, 72 | context: dirPath, 73 | entry: {}, 74 | target: 'node', 75 | plugins: [ 76 | new webpack.IgnorePlugin(/vertx/), 77 | new webpack.DefinePlugin(defineEnv), 78 | ], 79 | output: { 80 | path: functionsPath, 81 | filename: '[name].js', 82 | libraryTarget: 'commonjs', 83 | }, 84 | optimization: { 85 | nodeEnv, 86 | }, 87 | bail: true, 88 | devtool: false, 89 | }; 90 | 91 | fs.readdirSync(dirPath).forEach((file: string) => { 92 | if (file.match(/\.(m?js|ts)$/)) { 93 | var name = file.replace(/\.(m?js|ts)$/, ''); 94 | if (!name.match(new RegExp(testFilePattern))) { 95 | webpackConfig.entry[name] = './' + file; 96 | } 97 | } 98 | }); 99 | 100 | return webpackConfig; 101 | }; 102 | 103 | function getBabelTarget(envConfig: any, runtime: string | void) { 104 | const key = 'AWS_LAMBDA_JS_RUNTIME'; 105 | // If NodeJS runtime specified during l9 init 106 | if (runtime) return runtime; 107 | // Otherwise use user webpack settings if exists 108 | const runtimes = ['nodejs8.15.0', 'nodejs6.10.3']; 109 | const current = envConfig[key] || process.env[key] || 'nodejs8.15.0'; 110 | const unknown = runtimes.indexOf(current) === -1; 111 | return unknown ? '8.15.0' : current.replace(/^nodejs/, ''); 112 | } 113 | -------------------------------------------------------------------------------- /src/lib/deploy/deploy.ts: -------------------------------------------------------------------------------- 1 | import { join, parse } from 'path'; 2 | import { readFileSync, readdirSync, writeFileSync } from 'fs'; 3 | import axios, { AxiosResponse } from 'axios'; 4 | import { safeDump } from 'js-yaml'; 5 | import { createDeployArtifacts, createUserS3Bucket } from './deployController'; 6 | 7 | const DEPLOY_ENDPOINT = 'http://api.lambda9.cloud/lambda/deploy'; 8 | const S3_CREATE_LAMBDA_ENDPOINT = 'https://cli.lambda9.cloud/createbucket'; 9 | const CREATE_DOMAIN_ENDPOINT = 'https://cli.lambda9.cloud/deploydomain'; 10 | const SAVE_FUNCTIONS_ENDPOINT = 'https://cli.lambda9.cloud/savefunctions'; 11 | const LOGS_SUBSCRIPTION_ENDPOINT = 'https://cli.lambda9.cloud/subscribelogs'; 12 | const LOG_GROUP_PREFIX = '/aws/lambda/'; 13 | const BASE_DOMAIN = 'lambda9.cloud'; 14 | 15 | interface funcObj { 16 | funcName: string; 17 | funcDef: string; 18 | } 19 | 20 | export default ( 21 | user: string | void, 22 | accessKey: string, 23 | project: string | void, 24 | functionsSrc: string | void, 25 | functionsOutput: string | void 26 | ) => { 27 | return new Promise((resolve, reject) => { 28 | getFunctionsSourceCode(); 29 | const deployArtifacts = createDeployArtifacts( 30 | functionsOutput, 31 | join, 32 | { 33 | readFileSync, 34 | readdirSync, 35 | }, 36 | safeDump 37 | ); 38 | createUserS3Bucket(S3_CREATE_LAMBDA_ENDPOINT, user, axios.post) 39 | .then((response: any) => { 40 | const requestData = { 41 | user, 42 | project, 43 | ...deployArtifacts, 44 | }; 45 | axios({ 46 | method: 'post', 47 | url: DEPLOY_ENDPOINT, 48 | data: requestData, 49 | maxContentLength: Infinity, 50 | }) 51 | .then((response: AxiosResponse) => { 52 | const lambdaData = { 53 | endpoints, 54 | data: response.data, 55 | }; 56 | 57 | createDomain(project, project); 58 | saveFunctions(functionsSourceCode, project, accessKey); 59 | subscribeToLogs(logGroupPrefixes); 60 | 61 | return resolve(lambdaData); 62 | }) 63 | .catch(err => { 64 | return reject(err); 65 | }); 66 | }) 67 | .catch((err: Error) => { 68 | console.log('😓 Error making S3 buckets for lambda functions'); 69 | }); 70 | 71 | const logGroupPrefixes = createLogGroupPrefixes(deployArtifacts.funcArr, project); 72 | const functionsSourceCode = getFunctionsSourceCode(); 73 | const endpoints = createEndpoints(deployArtifacts.funcArr); 74 | }); 75 | 76 | function createDomain(subdomainPrefix: string | void, stackName: string | void) { 77 | const data = { 78 | domainName: `${subdomainPrefix}.${BASE_DOMAIN}`, 79 | stackName 80 | } 81 | 82 | axios({ 83 | method: "post", 84 | url: CREATE_DOMAIN_ENDPOINT, 85 | data 86 | }) 87 | .then((response: AxiosResponse) => { 88 | console.log(`\n${response.data}`); 89 | }) 90 | .catch(err => { 91 | console.log('😓 Error creating lambda subdomain'); 92 | }); 93 | } 94 | 95 | function saveFunctions(functions: any, projectName: string | void, accessKey: string) { 96 | const data = { 97 | functions, 98 | projectName, 99 | accessKey 100 | }; 101 | 102 | axios({ 103 | method: "post", 104 | url: SAVE_FUNCTIONS_ENDPOINT, 105 | data, 106 | maxContentLength: Infinity 107 | }) 108 | .then((response: AxiosResponse) => { 109 | console.log('Saved lambda functions'); 110 | }) 111 | .catch(err => { 112 | }); 113 | } 114 | 115 | function createLogGroupPrefixes(functions: any, projectName: string | void) { 116 | return functions.map((funcObj: funcObj) => { 117 | const funcName = parse(funcObj.funcName).name; 118 | return `${LOG_GROUP_PREFIX}${projectName}-${funcName}`; 119 | }) 120 | } 121 | 122 | function subscribeToLogs(logGroupsPrefixes: [any]) { 123 | const data = { 124 | logGroupsPrefixes 125 | }; 126 | 127 | axios({ 128 | method: "post", 129 | url: LOGS_SUBSCRIPTION_ENDPOINT, 130 | data 131 | }) 132 | .then((response: AxiosResponse) => { 133 | }) 134 | .catch(err => { 135 | }); 136 | } 137 | 138 | function createEndpoints(functions: any) { 139 | return functions.map((funcObj: funcObj) => { 140 | return parse(funcObj.funcName).name.toLowerCase(); 141 | }); 142 | } 143 | 144 | function getFunctionsSourceCode() { 145 | const funcArr: any = []; 146 | readdirSync(join(process.cwd(), String(functionsSrc))).forEach((file: string) => { 147 | const data = readFileSync( 148 | join(process.cwd(), `${functionsSrc}/${file}`), 149 | 'utf8' 150 | ); 151 | const funcObj: funcObj = { 152 | funcName: file, 153 | funcDef: data, 154 | }; 155 | funcArr.push(funcObj); 156 | }); 157 | return funcArr; 158 | } 159 | }; 160 | -------------------------------------------------------------------------------- /src/lib/deploy/deployController.ts: -------------------------------------------------------------------------------- 1 | const YAML_CONFIG_TEMPLATE: config = { 2 | AWSTemplateFormatVersion: '2010-09-09', 3 | Transform: 'AWS::Serverless-2016-10-31', 4 | Description: 'Deployed with Airfn CLI.', 5 | Outputs: { 6 | ApiGatewayId: { 7 | Value: { 8 | Ref: "ServerlessRestApi" 9 | } 10 | } 11 | }, 12 | Resources: {} 13 | }; 14 | 15 | interface config { 16 | AWSTemplateFormatVersion: string; 17 | Transform: string; 18 | Description: string; 19 | Resources: object; 20 | Outputs: object; 21 | } 22 | 23 | function createDeployArtifacts( 24 | functionsOutput: string | void, 25 | join: Function, 26 | fs: { readFileSync: Function; readdirSync: Function }, 27 | safeDump: Function 28 | ) { 29 | const funcArr: any = []; 30 | const yamlConfig: config = YAML_CONFIG_TEMPLATE; 31 | 32 | fs.readdirSync(join(process.cwd(), functionsOutput)).forEach((file: string) => { 33 | createFunctionResource(file, yamlConfig); 34 | const data = fs.readFileSync( 35 | join(process.cwd(), `${functionsOutput}/${file}`), 36 | 'utf8' 37 | ); 38 | const funcObj: object = { 39 | funcName: file, 40 | funcDef: data, 41 | }; 42 | funcArr.push(funcObj); 43 | }); 44 | 45 | return { 46 | yaml: safeDump(yamlConfig, { noCompatMode: true, noRefs: true}), 47 | funcArr, 48 | }; 49 | }; 50 | 51 | function createUserS3Bucket(endpoint: string, user: string | void, post: Function) { 52 | const data = { 53 | user 54 | }; 55 | return new Promise((resolve, reject) => { 56 | post(endpoint, data).then((response: any) => resolve(response.data)).catch((err: Error) => reject(err)); 57 | }); 58 | }; 59 | 60 | function createFunctionResource(fileName: string, yamlConfig: any): void { 61 | fileName = fileName.replace(/\.[^/.]+$/, ''); 62 | const funcTemplate: object = { 63 | Type: 'AWS::Serverless::Function', 64 | Properties: { 65 | Handler: `${fileName}.handler`, 66 | Runtime: 'nodejs8.10', 67 | CodeUri: '.', 68 | Description: 'A function deployed with Airfn CLI', 69 | MemorySize: 512, 70 | Timeout: 10, 71 | Events: { 72 | Api1: { 73 | Type: 'Api', 74 | Properties: { 75 | Path: `/${fileName}`.toLowerCase(), 76 | Method: 'ANY', 77 | }, 78 | }, 79 | }, 80 | }, 81 | }; 82 | yamlConfig.Resources[fileName] = funcTemplate; 83 | } 84 | 85 | export { createDeployArtifacts, createUserS3Bucket } -------------------------------------------------------------------------------- /src/lib/serve/serve.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import serve from './serveController'; 3 | import path from 'path'; 4 | import queryString from 'querystring'; 5 | import bodyParser from 'body-parser'; 6 | const createHandler = serve(path, queryString); 7 | 8 | import chalk from 'chalk'; 9 | 10 | function listen( 11 | src: string | void, 12 | port: number, 13 | useStatic: boolean, 14 | timeout: number 15 | ) : any { 16 | const app: express.Application = express(); 17 | app.use(bodyParser.json()); 18 | app.use(bodyParser.urlencoded({ extended: true })); 19 | app.get('/favicon.ico', function(req, res) { 20 | return res.status(204).end(); 21 | }); 22 | 23 | app.all('*', createHandler(src, false, 10), (req, res) => { 24 | return res.end(); 25 | }); 26 | 27 | const server: any = app.listen(port, () => { 28 | console.log(chalk.green(`Example app listening on port ${port}!`)); 29 | }); 30 | 31 | 32 | app.get('/favicon.ico', function(req, res) { 33 | res.status(204).end(); 34 | }); 35 | 36 | return { 37 | clearCache: (chunk : any) => { 38 | const module = path.join(process.cwd(), String(src), chunk); 39 | delete require.cache[require.resolve(module)]; 40 | } 41 | }; 42 | 43 | } 44 | export default listen; 45 | -------------------------------------------------------------------------------- /src/lib/serve/serveController.test.ts: -------------------------------------------------------------------------------- 1 | const { 2 | createHandler, 3 | createCallback, 4 | promiseHandler, 5 | } = require('./serveController')(); 6 | 7 | /* Sets up test mocks for Express request and response objects */ 8 | function setup() { 9 | const req = { 10 | body: {}, 11 | path: '', 12 | url: '', 13 | }; 14 | const res = { 15 | locals: { 16 | error: {}, 17 | lambdaResponse: {}, 18 | }, 19 | statusCode: null, 20 | body: '', 21 | headers: {}, 22 | }; 23 | const next = jest.fn(); 24 | Object.assign(res, { 25 | status: jest.fn( 26 | function status(this: object) { 27 | return this; 28 | }.bind(res) 29 | ), 30 | json: jest.fn( 31 | function json(this: object) { 32 | return this; 33 | }.bind(res) 34 | ), 35 | send: jest.fn( 36 | function send(this: object) { 37 | return this; 38 | }.bind(res) 39 | ), 40 | }); 41 | return { req, res, next }; 42 | } 43 | 44 | describe('createHandler', () => { 45 | test('Should have proper error object in res.locals if requiring function module fails', async () => { 46 | const { req, res, next } = setup(); 47 | req.path = '/helloasync'; 48 | const dir = '/functions'; 49 | const useStatic = false; 50 | const timeout = 5; 51 | const errorObj = { 52 | code: 500, 53 | type: 'Server', 54 | message: 'Loading function failed', 55 | }; 56 | await createHandler(dir, useStatic, timeout)(req, res, () => { 57 | expect(res.locals.error).toEqual(errorObj); 58 | }); 59 | }); 60 | 61 | test('Should have a proper error object in res.locals if lambda is not invoked before timeout', async () => { 62 | const { req, res, next } = setup(); 63 | req.path = '/helloasync'; 64 | const dir = '/functions'; 65 | const useStatic = false; 66 | const timeout = 5; 67 | const errorObj = { 68 | code: 400, 69 | type: 'Client', 70 | message: 'Failed to invoke function before timeout', 71 | }; 72 | await createHandler(dir, useStatic, timeout)(req, res, () => { 73 | expect(res.locals.error).not.toEqual(errorObj); 74 | }); 75 | }); 76 | 77 | test('Should have a proper lambdaResponse object in res.locals', async () => { 78 | const { req, res, next } = setup(); 79 | req.path = '/helloasync'; 80 | req.url = 'http://localhost:9000/helloasync'; 81 | const dir = '/functions'; 82 | const useStatic = false; 83 | const timeout = 5; 84 | const lambdaResponse = [ 85 | { 86 | statusCode: 200, 87 | body: 'Hello, World', 88 | }, 89 | ]; 90 | await createHandler(dir, useStatic, timeout)(req, res, () => { 91 | expect(res.locals.lambdaResponse).not.toEqual(lambdaResponse); 92 | }); 93 | }); 94 | }); 95 | 96 | describe('createCallback', () => { 97 | const res = { 98 | headers: { 99 | 'Access-Control-Allow-Origin': '*', 100 | 'Access-Control-Allow-Headers': 'Content-Type', 101 | }, 102 | statusCode: 200, 103 | body: 104 | 'Why should you never trust a pig with a ' + 105 | "secret? Because it's bound to squeal.", 106 | }; 107 | 108 | test('Should return a function', async () => { 109 | const res = {}; 110 | await expect(typeof createCallback(res)).toEqual('function'); 111 | }); 112 | 113 | test('Returned callback should be able to handle errors', async () => { 114 | const res = { 115 | headers: { 116 | 'Access-Control-Allow-Origin': '*', 117 | 'Access-Control-Allow-Headers': 'Content-Type', 118 | }, 119 | statusCode: 200, 120 | body: 121 | 'Why should you never trust a pig with a ' + 122 | "secret? Because it's bound to squeal.", 123 | }; 124 | const returnFunc = await createCallback(res); 125 | expect(returnFunc(new Error('this is an error'), null)).toBeInstanceOf( 126 | Error 127 | ); 128 | }); 129 | 130 | test('Callback should set proper response object with status code, headers, and body', async () => { 131 | const { res } = setup(); 132 | const lambdaResponse = { 133 | statusCode: 200, 134 | headers: { 135 | header1: 'Facebook', 136 | header2: 'Google', 137 | }, 138 | body: 'Hello, World', 139 | }; 140 | await createCallback(res)(null, lambdaResponse); 141 | await expect(res).toMatchObject(lambdaResponse); 142 | }); 143 | }); 144 | describe('promiseHandler', () => {}); 145 | -------------------------------------------------------------------------------- /src/lib/serve/serveController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | // TODO: Proper TypeScript types for modules 4 | export default (path: { join: Function }, queryString: { parse: Function }) => ( 5 | dir: string | void, 6 | useStatic: boolean, 7 | timeout: number 8 | ) => { 9 | return function(req: Request, res: Response, next: Function) { 10 | const fn: string = req.path.split('/').filter(name => name)[0]; 11 | const joinModPath = path.join(process.cwd(), dir, fn); 12 | const handler = require(joinModPath); 13 | const lambdaReq = { 14 | path: req.path, 15 | httpMethod: req.method, 16 | queryStringParameters: queryString.parse(req.url.split(/\?(.+)/)[1]), 17 | headers: req.headers, 18 | body: req.body, 19 | }; 20 | const callback = createCallback(res); 21 | const promise = handler.handler(lambdaReq, null, callback); 22 | Promise.all([promisifyHandler(promise, callback)]) // TODO: Implement promise with timeout 23 | .then(() => { 24 | return next(); 25 | }) 26 | .catch(err => { 27 | throw err; 28 | }); 29 | }; 30 | }; 31 | 32 | function createCallback(res: Response) { 33 | return function callback(err: Error | null, lambdaRes: any) { 34 | if (err) return err; // TODO: Proper error handling 35 | res.statusCode = lambdaRes.statusCode; 36 | for (let key in lambdaRes.headers) { 37 | res.setHeader(key, lambdaRes.headers[key]); 38 | } 39 | if (lambdaRes.body) { 40 | res.write(lambdaRes.body); 41 | } 42 | }; 43 | } 44 | 45 | function promisifyHandler(promise: { then: Function }, callback: Function) { 46 | if ( 47 | !promise || 48 | typeof promise.then !== 'function' || 49 | typeof callback !== 'function' 50 | ) 51 | return; 52 | 53 | return promise 54 | .then((data: object) => { 55 | callback(null, data); 56 | }) 57 | .catch((err: Error | null) => { 58 | callback(err, null); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type projConfig = { 2 | user?: string; 3 | project?: string; 4 | functionsSrc?: string; 5 | functionsOutput?: string; 6 | nodeRuntime?: string; 7 | port?: number; 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist" /* Redirect output structure to the directory. */, 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | /* Strict Type-Checking Options */ 25 | "strict": true /* Enable all strict type-checking options. */, 26 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | /* Module Resolution Options */ 39 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 40 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 41 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 42 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 43 | "typeRoots": [ 44 | "node_modules/@types" 45 | ] /* List of folders to include type definitions from. */, 46 | "types": [ 47 | "node", 48 | "express", 49 | "body-parser", 50 | "jest", 51 | "webpack" 52 | ] /* Type declaration files to be included in compilation. */, 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | }, 66 | "exclude": ["node_modules", "dist"] 67 | } 68 | --------------------------------------------------------------------------------