├── .gitignore ├── server ├── testHelpers │ └── globals.js ├── api │ ├── user │ │ ├── index.js │ │ ├── config.js │ │ ├── controller.js │ │ ├── test │ │ │ └── controller.test.js │ │ └── route.js │ └── index.js ├── core │ ├── logger.js │ ├── connectors │ │ ├── redis.js │ │ ├── mysql.js │ │ ├── mongo.js │ │ └── index.js │ ├── commonErrorHandler.js │ ├── shutdownManager.js │ └── bootstrapper.js ├── utils │ └── error.js └── index.js ├── .prettierrc.json ├── tools ├── templates │ ├── config.tpl │ ├── index.tpl │ ├── model.tpl │ ├── controller.tpl │ ├── route.tpl │ ├── repository.tpl │ └── service.tpl ├── lib │ ├── processman.js │ ├── logger.js │ ├── writer.js │ ├── generator.js │ └── renderer.js └── cli.js ├── config ├── development.js ├── production.js ├── local.js ├── default.js └── index.js ├── Dockerfile ├── .editorconfig ├── .eslintrc ├── index.js ├── .dockerignore ├── LICENSE.md ├── package.json ├── scripts ├── startup.sh └── wait-for.sh ├── docker-compose.yaml └── ReadMe.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | .env 4 | -------------------------------------------------------------------------------- /server/testHelpers/globals.js: -------------------------------------------------------------------------------- 1 | require('chai').should(); 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "endOfLine": "lf", 4 | "tabWidth": 2, 5 | "semi": true 6 | } -------------------------------------------------------------------------------- /server/api/user/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | config: require('./config'), 3 | route: require('./route') 4 | }; 5 | -------------------------------------------------------------------------------- /tools/templates/config.tpl: -------------------------------------------------------------------------------- 1 | const config = { 2 | ENDPOINT: '/{{resource.name}}', 3 | }; 4 | 5 | module.exports = config; 6 | -------------------------------------------------------------------------------- /config/development.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file will be rewritten in dev env by configuration management scripts 3 | */ 4 | module.exports = {}; 5 | -------------------------------------------------------------------------------- /config/production.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file will be rewritten in prod env by configuration management scripts 3 | */ 4 | module.exports = {}; 5 | -------------------------------------------------------------------------------- /server/api/user/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | ENDPOINT: '/user', // change this to your base api url 3 | }; 4 | 5 | module.exports = config; 6 | -------------------------------------------------------------------------------- /config/local.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Should contain same keys and values as in dev and prod config, but for 3 | * running in local 4 | */ 5 | module.exports = {}; 6 | -------------------------------------------------------------------------------- /server/core/logger.js: -------------------------------------------------------------------------------- 1 | const config = require('@config'); 2 | const debug = require('debug')(config.SERVER_NAME); 3 | 4 | module.exports = { 5 | log: debug 6 | }; 7 | -------------------------------------------------------------------------------- /config/default.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | SERVER_NAME: 'simpleNodeServer', 3 | DEFAULT_PORT: 8282, 4 | CONNECTION_CLOSING_TIME: 5000, 5 | WAIT_TIME_BEFORE_FORCE_SHUTDOWN: 10000, 6 | }; 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine3.9 2 | EXPOSE 8282 3 | 4 | ENV SERVICE_HOME /service 5 | RUN mkdir -p $SERVICE_HOME 6 | RUN apk add --no-cache bash 7 | WORKDIR $SERVICE_HOME 8 | COPY . . 9 | RUN npm ci --production 10 | -------------------------------------------------------------------------------- /tools/lib/processman.js: -------------------------------------------------------------------------------- 1 | function makeGoodExit() { 2 | process.exit(0); 3 | } 4 | 5 | function makeBadExit() { 6 | process.exit(1); 7 | } 8 | 9 | module.exports = { 10 | makeBadExit, 11 | makeGoodExit, 12 | }; 13 | -------------------------------------------------------------------------------- /server/utils/error.js: -------------------------------------------------------------------------------- 1 | /* eslint no-caller: 0 */ 2 | 3 | function throwError(initiatingMethod, errorMessage) { 4 | throw new Error(`${arguments.callee.caller.name}: ${errorMessage}`); 5 | } 6 | 7 | module.exports = { 8 | throwError 9 | }; 10 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | const configMap = { 2 | development: require('./development'), 3 | local: require('./local'), 4 | production: require('./production') 5 | }; 6 | 7 | module.exports = Object.assign(require('./default'), configMap[process.env.NODE_ENV || 'local']); 8 | -------------------------------------------------------------------------------- /server/api/user/controller.js: -------------------------------------------------------------------------------- 1 | class Controller { 2 | static async getUser() { 3 | return { 4 | name: 'John Doe', 5 | role: 'admin', 6 | actions: ['profile', 'settings'], 7 | }; 8 | } 9 | } 10 | 11 | module.exports = Controller; 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 80 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /tools/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const generator = require('./lib/generator'); 3 | 4 | (function exec() { 5 | const args = process.argv.slice(2); 6 | 7 | if (args && args.length) { 8 | const resourceName = args[0]; 9 | generator.generate(resourceName); 10 | } 11 | })(); 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "semi": 0, 9 | "space-before-function-paren": 0, 10 | "object-curly-spacing": 0 11 | }, 12 | "globals": { 13 | "require": true, 14 | "process": true, 15 | "module": true 16 | }, 17 | "extends": "standard" 18 | } -------------------------------------------------------------------------------- /server/api/user/test/controller.test.js: -------------------------------------------------------------------------------- 1 | const controller = require('../controller'); 2 | 3 | describe('user.controller', function () { 4 | describe('getUser', function () { 5 | it('should return dummy user object', async function () { 6 | let user = await controller.getUser(); 7 | user.name.should.equal('John Doe'); 8 | user.role.should.equal('admin'); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tools/lib/logger.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | function debug(message, ...args) { 4 | console.log('\n'); 5 | console.log(chalk.green(message), ...args); 6 | console.log('\n'); 7 | } 8 | 9 | function error(message, ...args) { 10 | console.log('\n'); 11 | console.error(chalk.red(message), ...args); 12 | console.log('\n'); 13 | } 14 | 15 | module.exports = { 16 | debug, 17 | error, 18 | }; 19 | -------------------------------------------------------------------------------- /tools/templates/index.tpl: -------------------------------------------------------------------------------- 1 | /** 2 | * @module {{resource.name}} 3 | * 4 | * @description 5 | * All the exportable files and functions should be added here. 6 | * This file exposes the portions of this module. Routes and endpoint 7 | * should be exposed by default. Apart from routes, anything else should 8 | * not be exposed unless needed otherwise. 9 | */ 10 | module.exports = { 11 | config: require('./config'), 12 | route: require('./route'), 13 | }; 14 | -------------------------------------------------------------------------------- /server/api/user/route.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const statusCodes = require('http-status-codes'); 3 | 4 | // we might need to access all the routing params from parent as well, 5 | // so the better practice is to have mergeParams: true 6 | const router = express.Router({mergeParams: true}); 7 | 8 | const controller = require('./controller'); 9 | 10 | router.get('/one', async function (req, res) { 11 | res.status(statusCodes.OK).send(await controller.getUser()); 12 | }); 13 | 14 | module.exports = router; 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const server = require('./server'); 2 | 3 | function options() { 4 | let options = {}; 5 | options.port = process.env.PORT; 6 | options.clientDirPath = process.env.CLIENT_DIR || null; 7 | options.indexPath = process.env.INDEX || null; 8 | options.staticDirPath = process.env.STATIC_DIR || null; 9 | return options; 10 | } 11 | 12 | /** 13 | * Initialize the service and start managing it. 14 | */ 15 | (async function () { 16 | const service = await server.start(options()); 17 | server.autoManageShutdown(service); 18 | })(); 19 | -------------------------------------------------------------------------------- /tools/templates/model.tpl: -------------------------------------------------------------------------------- 1 | /** 2 | * @module {{resource.name}} model 3 | * 4 | * @description 5 | * Depending on which ORM you use(e.g. mongoose, Joi, TypeORM etc.) 6 | * you should define the model for your resource here. If you need 7 | * multiple models for your api, consider defining a `models` folder 8 | * and putting all the model files inside with respective names. 9 | * 10 | * Example- 11 | * For an order management API the models folder might look like below- 12 | * 13 | * models 14 | * - order.item.js 15 | * - order.js 16 | */ 17 | module.exports = {}; 18 | -------------------------------------------------------------------------------- /tools/templates/controller.tpl: -------------------------------------------------------------------------------- 1 | /** 2 | * @module {{resource.name}} controller 3 | * 4 | * @description 5 | * The controller layer. Instead of directly writing any 6 | * business logics here, please use service(s). 7 | * 8 | * The basic controller is written as CRUD controller, but 9 | * you can write it any way to support your api endpoint. 10 | * For example, for a checkout API, the CRUD will not make 11 | * sense but a `checkout` function will. 12 | */ 13 | 14 | class Controller { 15 | static async get() {} 16 | static async remove() {} 17 | static async save() {} 18 | } 19 | 20 | module.exports = Controller; 21 | -------------------------------------------------------------------------------- /server/core/connectors/redis.js: -------------------------------------------------------------------------------- 1 | const redis = require('redis'); 2 | const logger = require('@core/logger'); 3 | 4 | async function connect() { 5 | const { REDIS_PORT } = process.env; 6 | const client = redis.createClient({ 7 | host: 'localhost', 8 | port: REDIS_PORT, 9 | }); 10 | return new Promise((resolve, reject) => { 11 | client.on('ready', () => { 12 | logger.log('redis is connected'); 13 | resolve(client); 14 | }); 15 | client.on('error', (err) => { 16 | logger.log('redis connection failed'); 17 | reject(err); 18 | }); 19 | }); 20 | } 21 | 22 | module.exports = { 23 | connect, 24 | }; 25 | -------------------------------------------------------------------------------- /server/core/commonErrorHandler.js: -------------------------------------------------------------------------------- 1 | const statusCodes = require('http-status-codes'); 2 | 3 | const logger = require('@core/logger'); 4 | 5 | /** 6 | * Takes care of any uncaught error that was not handled specifically for 7 | * a specific app instance. 8 | * @param app Express app instance 9 | */ 10 | function attachWithApp(app) { 11 | app.use((err, req, res, next) => { 12 | if (!err) { 13 | next(); 14 | } 15 | 16 | logger.log('Error: %s %O', err.message, err.stack); 17 | res.status(statusCodes.INTERNAL_SERVER_ERROR).send({ 18 | 'message': 'Internal Server Error' 19 | }); 20 | }); 21 | } 22 | 23 | module.exports = { 24 | attachWithApp 25 | }; 26 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # source control 2 | .git 3 | .gitignore 4 | 5 | # IDE 6 | .idea/ 7 | .vscode/ 8 | .editorconfig 9 | jsconfig.json 10 | 11 | # dependencies 12 | /.pnp 13 | .pnp.js 14 | node_modules/ 15 | 16 | # testing 17 | /coverage 18 | .nyc_output 19 | .mocha.setup.js 20 | 21 | # docker 22 | Dockerfile 23 | docker-compose.yml 24 | 25 | # misc 26 | .DS_Store 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | app.log 36 | 37 | .eslintrc 38 | .prettierrc 39 | 40 | README.md 41 | /docs 42 | 43 | # gitlab 44 | /builds 45 | /cache 46 | .gitlab-ci.yml 47 | 48 | # kubernetes 49 | /deployment 50 | Makefile 51 | -------------------------------------------------------------------------------- /tools/templates/route.tpl: -------------------------------------------------------------------------------- 1 | /** 2 | * @module {{resource.name}} router 3 | * 4 | * @description 5 | * All routing for this resource/api should be defined here. 6 | * This route then gets added to the base api routing. 7 | */ 8 | 9 | const express = require('express'); 10 | const statusCodes = require('http-status-codes'); 11 | 12 | // we might need to access all the routing params from parent as well, 13 | // so the practice is to have mergeParams: true 14 | const router = express.Router({ mergeParams: true }); 15 | 16 | const controller = require('./controller'); 17 | 18 | // a sample routing 19 | router.get('/:id', async function (req, res) { 20 | // res.status(statusCodes.OK).send(await controller.get({ id: req.params.id })); 21 | }); 22 | 23 | module.exports = router; 24 | -------------------------------------------------------------------------------- /server/core/connectors/mysql.js: -------------------------------------------------------------------------------- 1 | const { Sequelize } = require('sequelize'); 2 | const logger = require('@core/logger'); 3 | 4 | async function connect() { 5 | const { 6 | MYSQL_USER: username, 7 | MYSQL_PASSWORD: password, 8 | MYSQL_DB: database, 9 | MYSQL_PORT: port, 10 | MYSQL_HOSTNAME: host, 11 | } = process.env; 12 | 13 | const sequelize = new Sequelize({ 14 | username, 15 | password, 16 | database, 17 | port, 18 | host, 19 | dialect: 'mysql', 20 | }); 21 | 22 | try { 23 | await sequelize.authenticate(); 24 | logger.log('mysql is connected'); 25 | return sequelize; 26 | } catch (ex) { 27 | logger.log('mysql connection failed'); 28 | return Promise.reject(ex); 29 | } 30 | } 31 | 32 | module.exports = { 33 | connect, 34 | }; 35 | -------------------------------------------------------------------------------- /server/core/connectors/mongo.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const logger = require('@core/logger'); 3 | 4 | async function connect() { 5 | const { 6 | MONGO_USERNAME, 7 | MONGO_PASSWORD, 8 | MONGO_HOSTNAME, 9 | MONGO_PORT, 10 | MONGO_DB, 11 | } = process.env; 12 | 13 | const url = `mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_HOSTNAME}:${MONGO_PORT}/${MONGO_DB}?authSource=admin`; 14 | 15 | const options = { 16 | useNewUrlParser: true, 17 | connectTimeoutMS: 10000, 18 | useUnifiedTopology: true, 19 | }; 20 | 21 | try { 22 | await mongoose.connect(url, options); 23 | logger.log('mongo db is connected'); 24 | } catch (ex) { 25 | logger.log('mongo db connection failed'); 26 | return Promise.reject(ex); 27 | } 28 | } 29 | 30 | module.exports = { 31 | connect, 32 | }; 33 | -------------------------------------------------------------------------------- /server/core/connectors/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const logger = require('@core/logger'); 4 | 5 | async function connectAll() { 6 | const ignore = [ 7 | 'index', 8 | // list down the connectors that should be ignored 9 | 'mysql', 10 | ]; 11 | 12 | const connectors = fs 13 | .readdirSync(path.resolve(__dirname)) 14 | .filter((connector) => { 15 | connector = connector.replace('.js', ''); 16 | return !ignore.includes(connector); 17 | }) 18 | .map((connector) => { 19 | return require(`./${connector}`).connect(); 20 | }); 21 | 22 | try { 23 | const connectionObjects = await Promise.all(connectors); 24 | return connectionObjects; 25 | } catch (ex) { 26 | logger.log('connectors.connectAll', ex); 27 | process.exit(1); 28 | } 29 | } 30 | 31 | module.exports = { 32 | connectAll, 33 | }; 34 | -------------------------------------------------------------------------------- /server/api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // Any new resource api should imported here and then registered to 5 | // router with proper api endpoint prefix (e.g /user user.route, /items items.route etc.) 6 | // 7 | // Do not remove the /** --route:import-- */ placeholder, if you use the cli to generate 8 | // api resources, this works as placeholder to inject new route file requires. 9 | // 10 | // If you add a require manually, add it above the /** --route:import-- */ line. 11 | const user = require('./user'); 12 | /** --route:import-- */ 13 | 14 | // Do not remove the /** --route-- */ placeholder, if you use the cli to generate 15 | // api resources, this works as placeholder to inject new routes. 16 | // 17 | // If you add a require manually, add it above the /** --route-- */ line. 18 | router.use(user.config.ENDPOINT, user.route); 19 | /** --route-- */ 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /tools/templates/repository.tpl: -------------------------------------------------------------------------------- 1 | /** 2 | * @module {{resource.name}} repository 3 | * 4 | * @description 5 | * This is the layer that works as the gateway to communicate 6 | * to and from db. This layer takes care of retrieving and saving 7 | * persistent objects. Keeping this separate allows you to keep 8 | * db integration tied to one layer, while keeping the upper 9 | * layer(e.g. service) contract intact. Hence, replacing with a 10 | * new persistence option integration is easy and quick. This is 11 | * where you put your ORM codes to access your models. 12 | * 13 | * A single repository can handle db communications for all the 14 | * models. That is absolutely fine. But if you want to have separate 15 | * repositories for each models, that's also possible. But make sure 16 | * to avoid repeting codes, you define a core repository, that is 17 | * used by all others. 18 | */ 19 | 20 | class Repository { 21 | } 22 | 23 | module.exports = Repository; -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018 © Munim Dibosh 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /tools/templates/service.tpl: -------------------------------------------------------------------------------- 1 | /** 2 | * @module {{resource.name}} service 3 | * 4 | * @description 5 | * This is where you should use the repositories for different models 6 | * and add your business logics to support controller demand. 7 | * The data transfer should always be- 8 | * 9 | * controller -> [request/domain object] -> service 10 | * service -> [model/db object] -> repository 11 | * repository -> [model/db object] -> service 12 | * service -> [response/domain object] -> controller 13 | * 14 | * Service should always use repositories to get, save, delete persistence 15 | * objects. It should never directly communicate with database. 16 | * 17 | * If you have multiple services, consider adding them inside a folder called 18 | * `services` and give every file respective names. 19 | * 20 | * Example: 21 | * 22 | * For a checkout API, the services folder may look like- 23 | * 24 | * services/ 25 | * - checkout.service.js 26 | * - payment.service.js 27 | * - notification.service.js 28 | */ 29 | 30 | class Service { 31 | } 32 | 33 | module.exports = Service; -------------------------------------------------------------------------------- /tools/lib/writer.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | /** 5 | * Given a filemap of filename vs file content along with 6 | * a folder path, creates all the .yml files. 7 | * @param {*} rootPath the absolute path of where the output folder exists 8 | * @param {*} fileMap contains file content against it's name as key 9 | * @param {*} folder the output folder 10 | */ 11 | function writeFiles(rootPath, fileMap, folder) { 12 | Object.keys(fileMap).forEach((filename) => { 13 | write( 14 | rootPath, 15 | `${filename}.js`.replace('.tpl', ''), 16 | fileMap[filename], 17 | folder, 18 | ); 19 | }); 20 | } 21 | 22 | function write(rootPath, filename, content, folder = '') { 23 | const embeddedDir = path.dirname(filename); 24 | const dumpDirPath = path.join(rootPath, folder, embeddedDir); 25 | if (!fs.existsSync(dumpDirPath)) { 26 | fs.mkdirSync(dumpDirPath, {recursive: true}); 27 | } 28 | const dumpPath = path.join(dumpDirPath, path.basename(filename)); 29 | fs.writeFileSync(dumpPath, content); 30 | } 31 | 32 | module.exports = { 33 | writeFiles, 34 | write, 35 | }; 36 | -------------------------------------------------------------------------------- /tools/lib/generator.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const logger = require('./logger'); 3 | const pman = require('./processman'); 4 | const renderer = require('./renderer'); 5 | const writer = require('./writer'); 6 | 7 | const outputDir = path.resolve(__dirname, '../../server/api'); 8 | 9 | /** 10 | * Generate resource files and add resource route to api index. 11 | * 12 | * @param {*} resourceName 13 | */ 14 | async function generate(resourceName) { 15 | resourceName = resourceName.toLowerCase(); 16 | if (/[._ ]/.test(resourceName)) { 17 | logger.error( 18 | 'resource names of these formats are allowed: users, parkings, paid-customers, order-items etc.' 19 | ); 20 | pman.makeBadExit(); 21 | } 22 | 23 | const folderPath = path.join(outputDir, resourceName); 24 | 25 | try { 26 | writer.writeFiles( 27 | folderPath, 28 | renderer.renderBaseFiles({ resource: { name: resourceName } }) 29 | ); 30 | const indexFileContent = renderer.renderIndexFile( 31 | path.join(outputDir, 'index.js'), 32 | resourceName, 33 | resourceName 34 | ); 35 | writer.write(outputDir, 'index.js', indexFileContent); 36 | logger.debug('completed generating the resource files and adding route to api index'); 37 | } catch (err) { 38 | logger.error('failed to generate necessary files', err); 39 | pman.makeBadExit(); 40 | } 41 | } 42 | 43 | module.exports = { 44 | generate, 45 | }; 46 | -------------------------------------------------------------------------------- /server/core/shutdownManager.js: -------------------------------------------------------------------------------- 1 | const logger = require('@core/logger'); 2 | const config = require('@config'); 3 | 4 | /** 5 | * A basic shutdown manager for the node backend. In case of any emergencies, 6 | * if manual interruption is initiated, it can do graceful shutdown of pending 7 | * connections and rollbacks. 8 | * @param server An already spawn server instance 9 | */ 10 | function manage(server) { 11 | let connections = []; 12 | 13 | let shutDown = () => { 14 | logger.log('Received kill signal, shutting down gracefully'); 15 | 16 | server.close(() => { 17 | logger.log('Closed out remaining connections'); 18 | process.exit(0); 19 | }); 20 | 21 | connections.forEach((curr) => { 22 | curr.end(); 23 | }); 24 | 25 | setTimeout(() => { 26 | connections.forEach((curr) => { 27 | curr.destroy(); 28 | }); 29 | }, config.CONNECTION_CLOSING_TIME); 30 | 31 | setTimeout(() => { 32 | logger.log( 33 | 'Could not close connections in time, forcefully shutting down' 34 | ); 35 | process.exit(1); 36 | }, config.WAIT_TIME_BEFORE_FORCE_SHUTDOWN); 37 | }; 38 | 39 | process.on('SIGTERM', shutDown); 40 | process.on('SIGINT', shutDown); 41 | 42 | server.on('connection', (connection) => { 43 | connections.push(connection); 44 | logger.log('%s connections currently open', connections.length); 45 | connection.on('close', function () { 46 | connections = connections.filter((curr) => { 47 | return curr !== connection; 48 | }); 49 | }); 50 | }); 51 | } 52 | 53 | module.exports = { 54 | manage 55 | }; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-node-server", 3 | "version": "1.0.0", 4 | "description": "A simple enough node server scaffolding to get you started on your project immediately", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=10.13.0" 8 | }, 9 | "_moduleAliases": { 10 | "@root": "server", 11 | "@core": "server/core", 12 | "@api": "server/api", 13 | "@config": "config/" 14 | }, 15 | "scripts": { 16 | "start": "DEBUG=simpleNodeServer node index.js", 17 | "test": "mocha -r server/testHelpers/globals.js server/**/*.test.js", 18 | "lint": "eslint server/", 19 | "add-resource": "node ./tools/cli.js" 20 | }, 21 | "keywords": [ 22 | "node", 23 | "scaffold", 24 | "expressjs", 25 | "server", 26 | "node", 27 | "project", 28 | "nodejs", 29 | "server", 30 | "simple", 31 | "http", 32 | "boilerplate", 33 | "api-design", 34 | "microservices", 35 | "backend" 36 | ], 37 | "author": "Munim Dibosh", 38 | "license": "ISC", 39 | "dependencies": { 40 | "cors": "^2.8.5", 41 | "debug": "^4.1.0", 42 | "express": "^4.16.4", 43 | "handlebars": "^4.7.7", 44 | "http-status-codes": "^1.3.0", 45 | "module-alias": "^2.2.2", 46 | "mongoose": "^5.11.16", 47 | "mysql2": "^2.2.5", 48 | "redis": "^3.0.2", 49 | "sequelize": "^6.5.0" 50 | }, 51 | "devDependencies": { 52 | "chai": "^4.2.0", 53 | "chalk": "^4.1.0", 54 | "eslint": "5.10.0", 55 | "eslint-config-standard": "12.0.0", 56 | "eslint-plugin-import": "^2.14.0", 57 | "eslint-plugin-node": "^8.0.0", 58 | "eslint-plugin-promise": "^4.0.1", 59 | "eslint-plugin-standard": "^4.0.0", 60 | "mocha": "^8.1.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /scripts/startup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script supports the following environment vars: 4 | # - WEB_MEMORY: the amount of memory each 5 | # process is expected to consume, in MB. 6 | 7 | if [[ -n "$WEB_MEMORY" ]]; then 8 | # The heap contains two main areas: 9 | # 10 | # New Space: all newly allocated objects are created here first. 11 | # The new space is often small (typically 1-8 MB), and it is 12 | # fast to collect garbage here. 13 | # 14 | # Old Space: any objects which are not garbage collected from 15 | # New Space eventually end up here. The vast majority of your 16 | # heap will be consumed by Old Space. Garbage collection is slower 17 | # here, as the size of Old Space is much larger than New Space, 18 | # and a different mechanism is employed to actually perform the collection. 19 | # 20 | # For this reason, garbage collection is only performed when there is not 21 | # much room left in Old Space. So, it makes sense to concentrate on the 22 | # heap’s Old Space when targeting memory usage. 23 | 24 | 25 | # The WEB_MEMORY environment variable is set. Set the `mem_old_space_size` 26 | # flag to 4/5 of the available memory. 4/5 has been determined via trial 27 | # and error to be the optimum value, to try and ensure that v8 uses the 28 | # available memory. 29 | mem_node_old_space=$((($WEB_MEMORY*4)/5)) 30 | echo "Provided memory: $WEB_MEMORY, Memory for Old Space: $mem_node_old_space" 31 | node_args="--max_old_space_size=$mem_node_old_space $node_args" 32 | fi 33 | 34 | export NODE_OPTIONS=${node_args} 35 | export DEBUG=formatWebApp:* 36 | 37 | echo "Starting app:" 38 | echo "> npm start" 39 | 40 | # Start the process using `exec`. This ensures that when node exits, the exit 41 | # code is passed up to the caller of this script. 42 | exec npm start -------------------------------------------------------------------------------- /scripts/wait-for.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # original script: https://github.com/eficode/wait-for/blob/master/wait-for 4 | 5 | TIMEOUT=15 6 | QUIET=0 7 | 8 | echoerr() { 9 | if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi 10 | } 11 | 12 | usage() { 13 | exitcode="$1" 14 | cat << USAGE >&2 15 | Usage: 16 | $cmdname host:port [-t timeout] [-- command args] 17 | -q | --quiet Do not output any status messages 18 | -t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout 19 | -- COMMAND ARGS Execute command with args after the test finishes 20 | USAGE 21 | exit "$exitcode" 22 | } 23 | 24 | wait_for() { 25 | for i in `seq $TIMEOUT` ; do 26 | nc -z "$HOST" "$PORT" > /dev/null 2>&1 27 | 28 | result=$? 29 | if [ $result -eq 0 ] ; then 30 | if [ $# -gt 0 ] ; then 31 | exec "$@" 32 | fi 33 | exit 0 34 | fi 35 | sleep 1 36 | done 37 | echo "Operation timed out" >&2 38 | exit 1 39 | } 40 | 41 | while [ $# -gt 0 ] 42 | do 43 | case "$1" in 44 | *:* ) 45 | HOST=$(printf "%s\n" "$1"| cut -d : -f 1) 46 | PORT=$(printf "%s\n" "$1"| cut -d : -f 2) 47 | shift 1 48 | ;; 49 | -q | --quiet) 50 | QUIET=1 51 | shift 1 52 | ;; 53 | -t) 54 | TIMEOUT="$2" 55 | if [ "$TIMEOUT" = "" ]; then break; fi 56 | shift 2 57 | ;; 58 | --timeout=*) 59 | TIMEOUT="${1#*=}" 60 | shift 1 61 | ;; 62 | --) 63 | shift 64 | break 65 | ;; 66 | --help) 67 | usage 0 68 | ;; 69 | *) 70 | echoerr "Unknown argument: $1" 71 | usage 1 72 | ;; 73 | esac 74 | done 75 | 76 | if [ "$HOST" = "" -o "$PORT" = "" ]; then 77 | echoerr "Error: you need to provide a host and port to test." 78 | usage 2 79 | fi 80 | 81 | wait_for "$@" 82 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | // start - initialize module aliases 2 | require('module-alias/register'); 3 | // end - initialize module aliases 4 | 5 | const apiRoute = require('@api'); 6 | const bootstrapper = require('@core/bootstrapper'); 7 | const commonErrorHandler = require('@core/commonErrorHandler'); 8 | const connectors = require('@core/connectors'); 9 | const config = require('@config'); 10 | const shutDownManager = require('@core/shutdownManager'); 11 | const logger = require('@core/logger'); 12 | 13 | /** 14 | * Creates a server instance 15 | * @param {*} options Options; can contain either a `clientPath` or `indexPath` together with 16 | * a `resourcesPath`. 17 | */ 18 | async function start(options) { 19 | options = options || {}; 20 | const port = options.port || config.DEFAULT_PORT; 21 | const app = _createApp(options); 22 | await connectors.connectAll(); 23 | return app.listen(port, function () { 24 | logger.log(`server started on port ${port}`); 25 | }); 26 | } 27 | 28 | /** 29 | * Manages force shutdown cases by closing all the open connections 30 | * and house keeping. 31 | * @param {*} server An server instance created by invoking `start` 32 | */ 33 | function autoManageShutdown(server) { 34 | shutDownManager.manage(server); 35 | } 36 | 37 | /** 38 | * Helper method to create an express app with necessary setup. 39 | * @param {*} options Options; can contain either a `clientPath` or `indexPath` together with 40 | * a `resourcesPath` 41 | */ 42 | function _createApp(options) { 43 | let app; 44 | options = options || {}; 45 | const { clientDirPath, indexPath, staticDirPath } = options; 46 | /** 47 | * App is bootstrapped only after all the models and routes have 48 | * been loaded using `apiRoute` 49 | */ 50 | if (indexPath && staticDirPath) { 51 | app = bootstrapper.initiateWithIndexAndStaticDir(indexPath, staticDirPath); 52 | } else { 53 | app = bootstrapper.initiate(clientDirPath); 54 | } 55 | 56 | // apis are available under /api prefix 57 | app.use('/api', apiRoute); 58 | 59 | // error handler middleware has to be attached at the very end 60 | commonErrorHandler.attachWithApp(app); 61 | 62 | return app; 63 | } 64 | 65 | module.exports = { 66 | autoManageShutdown, 67 | start 68 | }; 69 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | # If you want to use mysql instead of mongodb, please move the entire mysql 4 | # block from below under `services`. You should then uncomment the 5 | # mysql related env vars in app_svc environment section. Do not forget 6 | # to uncomment mysql-data under volumes. 7 | x-disabled: 8 | mysql: 9 | container_name: mysql 10 | image: mysql:5.7 11 | restart: always 12 | environment: 13 | - MYSQL_DATABASE=$DB_NAME 14 | # Uncomment following line, if root user does not have a 15 | # password. And remove MYSQL_ROOT_PASSWORD 16 | # - MYSQL_ALLOW_EMPTY_PASSWORD=yes 17 | - MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PWORD 18 | # Uncomment next lines if you have a different user 19 | # than root. 20 | # - MYSQL_USER=$MYSQL_USER 21 | # - MYSQL_PASSWORD=$MYSQL_PWORD 22 | ports: 23 | - $MYSQL_HOST_PORT:$MYSQL_PORT 24 | volumes: 25 | - mysql-data:/var/lib/mysql 26 | networks: 27 | - svc-network 28 | 29 | services: 30 | build_image: 31 | build: . 32 | image: services/sns:${VERSION} # use your own image name e.g. rancher/my-app:{VERSION} 33 | 34 | app_svc: 35 | container_name: sns-service 36 | image: services/sns:${VERSION} 37 | depends_on: [build_image] 38 | restart: on-failure 39 | environment: 40 | - MONGO_USERNAME=$MONGO_USERNAME 41 | - MONGO_PASSWORD=$MONGO_PASSWORD 42 | - MONGO_HOSTNAME=mongo 43 | - MONGO_PORT=$MONGO_PORT 44 | - MONGO_DB=$DB_NAME 45 | # Uncomment if you are using mysql 46 | # - MYSQL_DB=$DB_NAME 47 | # - MYSQL_HOSTNAME=mysql 48 | # - MYSQL_USER=$MYSQL_ROOT_USER # feel free to use a different user 49 | # - MYSQL_PASSWORD=$MYSQL_ROOT_PWORD 50 | # - MYSQL_PORT=$MYSQL_PORT 51 | ports: 52 | - $SVC_HOST_PORT:8282 53 | networks: 54 | - svc-network 55 | command: bash scripts/startup.sh 56 | 57 | mongo: 58 | container_name: mongo 59 | image: mongo:4.1.8-xenial 60 | restart: always 61 | environment: 62 | - MONGO_INITDB_ROOT_USERNAME=$MONGO_USERNAME 63 | - MONGO_INITDB_ROOT_PASSWORD=$MONGO_PASSWORD 64 | - MONGO_INITDB_DATABASE=$DB_NAME 65 | ports: 66 | - $MONGO_HOST_PORT:$MONGO_PORT 67 | volumes: 68 | - mongo-data:/data/db 69 | networks: 70 | - svc-network 71 | 72 | networks: 73 | svc-network: 74 | driver: bridge 75 | 76 | volumes: 77 | mongo-data: 78 | # mysql-data: # uncomment if using mysql -------------------------------------------------------------------------------- /tools/lib/renderer.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const handlebars = require('handlebars'); 4 | const pman = require('./processman'); 5 | const logger = require('./logger'); 6 | 7 | const baseTplDirPath = path.resolve(__dirname, '../templates'); 8 | 9 | /** 10 | * Renders API index file with necessary changes. 11 | * 12 | * @param {*} apiIndexPath 13 | * @param {*} resourceFolderName 14 | * @param {*} resourcePrefix 15 | */ 16 | function renderIndexFile(apiIndexPath, resourceFolderName, resourcePrefix) { 17 | const content = fs.readFileSync(apiIndexPath, 'utf8'); 18 | const normalisedName = resourceFolderName.replace( 19 | /-([a-zA-Z])/g, 20 | (m) => m[1] && m[1].toUpperCase() 21 | ); 22 | const importLine = `const ${normalisedName} = require('./${resourceFolderName}');\n/** --route:import-- */`; 23 | const hookLine = `router.use(${normalisedName}.config.ENDPOINT, ${normalisedName}.route);\n/** --route-- */`; 24 | 25 | return content 26 | .replace('/** --route:import-- */', importLine) 27 | .replace('/** --route-- */', hookLine); 28 | } 29 | 30 | function renderBaseFiles(data, ignoreFiles = []) { 31 | return renderFiles(baseTplDirPath, data, ignoreFiles); 32 | } 33 | 34 | /** 35 | * Render template files in a given dir. 36 | * @param {*} dirPath 37 | * @param {*} data 38 | * @param {array} ignoreFiles Files that should be ignored, e.g. ['ingress', 'hpa'] 39 | */ 40 | function renderFiles(dirPath, data, ignoreFiles = []) { 41 | if (!dirPath || !data) { 42 | throw new Error('Either dirPath or data is not provided.'); 43 | } 44 | 45 | const tplContentMap = {}; 46 | fs.readdirSync(dirPath).forEach((f) => { 47 | if (!ignoreFiles.includes(f)) { 48 | const tplPath = path.join(dirPath, f); 49 | tplContentMap[f] = render(tplPath, data); 50 | } 51 | }); 52 | 53 | return tplContentMap; 54 | } 55 | 56 | /** 57 | * Render a template file with given data. 58 | * 59 | * @param {*} tplPath 60 | * @param {*} data 61 | */ 62 | function render(tplPath, data) { 63 | if (!fs.existsSync(tplPath)) { 64 | logger.error(`template render failed; ${tplPath} was not found.`); 65 | pman.makeBadExit(); 66 | } 67 | 68 | try { 69 | const tpl = fs.readFileSync(tplPath, 'utf8'); 70 | const compiled = handlebars.compile(tpl); 71 | return compiled(data); 72 | } catch (err) { 73 | logger.error(`template render failed.`, err); 74 | pman.makeBadExit(); 75 | } 76 | } 77 | 78 | module.exports = { 79 | renderBaseFiles, 80 | renderIndexFile, 81 | }; 82 | -------------------------------------------------------------------------------- /server/core/bootstrapper.js: -------------------------------------------------------------------------------- 1 | const cors = require('cors'); 2 | const express = require('express'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const errorUtils = require('@root/utils/error'); 7 | const logger = require('@core/logger'); 8 | 9 | /** 10 | * Creates a barebone express app with basic setup. 11 | */ 12 | function _initialize_() { 13 | let app = express(); 14 | 15 | app.use(cors()); 16 | app.use(express.json()); 17 | 18 | app.get('/health', (req, res) => { 19 | res.status(200).send({ message: 'Up and running' }); 20 | }); 21 | 22 | return app; 23 | } 24 | 25 | /** 26 | * The main entry point that creates the express app instance which later spins 27 | * up as server. 28 | * @param clientPath The absolute path for client app folder which needs to be 29 | * served by this app, must have an index.html with resources folder named 30 | * as any of these- 31 | * `resources`, `static`, `dist` 32 | * 33 | * So, the file structure inside the client folder must be like below- 34 | * ``` 35 | * client 36 | * - index.html 37 | * - dist(or static or resources)/ 38 | * - ... 39 | * 40 | * If the `clientPath` is not given, returns a basic express app instance. 41 | * ``` 42 | * @returns {Function} 43 | */ 44 | 45 | function initiate(clientPath) { 46 | let app = _initialize_(); 47 | 48 | if (clientPath) { 49 | if (fs.existsSync(clientPath)) { 50 | let staticPaths = ['dist', 'resources', 'static']; 51 | 52 | staticPaths.forEach((staticPath) => { 53 | app.use(express.static(path.resolve(clientPath, staticPath))); 54 | }); 55 | 56 | app.get('/', function (req, res) { 57 | res.sendFile(path.resolve(clientPath, 'index.html')); 58 | }); 59 | } else { 60 | logger.log('invalid client folder path; try absolute path or make sure it exists'); 61 | } 62 | } 63 | 64 | return app; 65 | } 66 | 67 | /** 68 | * Explicitly define the client index.html absolute path and static directory to serve 69 | * @param indexPath 70 | * @param staticDirPath 71 | */ 72 | function initiateWithIndexAndStaticDir(indexPath, staticDirPath) { 73 | let app = _initialize_(); 74 | 75 | if (indexPath && staticDirPath && fs.existsSync(indexPath) && fs.existsSync(staticDirPath)) { 76 | app.use(express.static(staticDirPath)); 77 | app.get('/', function (req, res) { 78 | res.sendFile(indexPath); 79 | }); 80 | } else { 81 | errorUtils.throwError('index path and/or static dir path is invalid or make sure they exist'); 82 | return; 83 | } 84 | 85 | return app; 86 | } 87 | 88 | module.exports = { 89 | initiate, 90 | initiateWithIndexAndStaticDir 91 | }; 92 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 |
8 | Simple enough service scaffolding in node and express; to get you started on your project immediately- with right folder structure and architecture practices in place 🤟🏼 9 |
10 | 11 | 12 | 13 | ## Table of Contents 14 | 15 | - [Features](#features) 16 | - [Usage](#usage) 17 | - [Serving Frontend](#serving-frontend) 18 | - [Folder Structure](#folder-structure) 19 | - [Adding new API Resource](#adding-new-api-resource) 20 | - [Others](#others) 21 | 22 | 23 | 24 | ## Features 25 | 26 | - Don't worry about the boilerplate anymore, jump right into writing your API 27 | resources 28 | 29 | - Easily start serving your frontend; great option to create a BFF right away 30 | 31 | - Have error handling and graceful shutdown support out of the box 32 | 33 | - Structure your code in a domain driven approach- with right architectural practices in place 34 | 35 | - Has `mongo` connectivity built-in, simply define schemas and start writing stateful APIs 36 | 37 | - Dockerized 38 | ## Usage 39 | 40 | ### Without Docker 41 | 42 | - `npm install` 43 | 44 | - Create a `.env` file using the following content, feel free to change username and password as you please: 45 | 46 | ``` 47 | # COMMON 48 | DB_NAME=sns-db 49 | 50 | # SERVICE 51 | VERSION=1.0.0 52 | SVC_HOST_PORT=8282 53 | 54 | # MONGO_DB 55 | MONGO_USERNAME=sns-user 56 | MONGO_PASSWORD=sns-012345 57 | MONGO_PORT=27017 58 | 59 | # To expose it in host network as well, please specify a port below. Change it to 60 | # any other ports, if the port is already in use in host. 61 | MONGO_HOST_PORT=27018 62 | 63 | # MYSQL 64 | MYSQL_ROOT_USER=root 65 | MYSQL_ROOT_PWORD=root12345 # you can remove this if MYSQL_ALLOW_EMPTY_PASSWORD is set in docker-compose 66 | MYSQL_PORT=3306 67 | 68 | # To expose it in host network as well, please specify a port below. Change it to 69 | # any other ports, if the port is already in use in host. 70 | MYSQL_HOST_PORT=3307 71 | ``` 72 | 73 | - Make sure if you have your local `mongo` running. Then, this will start the server in port `8282`: `npm start` 74 | 75 | - To use `mysql`, make sure you have local mysql running with the above setup in `.env` file. Then in `server/core/connectors/index.js` comment out or remove the `mysql` option from ignore list and add `mongo` instead 76 | 77 | ### With Docker 78 | 79 | - Make sure you have created an `.env` file stated above with same content 80 | 81 | - This will run the service at `8282` port with `mongo` connectivity by default: 82 | 83 | `docker-compose build && docker-compose up -d` 84 | 85 | - To check logs: `docker-compose logs` 86 | 87 | - To shutdown: `docker-compose down` 88 | 89 | - To use `mysql` instead, check the `docker-compose` file and follow the instructions given in comments. Finally, do not forget to make the changes in `server/core/connectors/index.js` as described above and run `docker-compose build` once 90 | 91 | ## Serving Frontend 92 | 93 | ### Without Docker 94 | 95 | If your frontend dir has following structure 96 | ``` 97 | client/ 98 | | - index.html 99 | | - resources/ <-- or may be static/ or dist/ 100 | ``` 101 | 102 | Then, this can easily be served using- 103 | ``` 104 | CLIENT_DIR=