├── .applications ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── lib ├── common.d.ts ├── common.js ├── db.d.ts └── db.js ├── log └── .gitkeep ├── main.js ├── package-lock.json ├── package.json ├── src ├── http.js ├── loader.js ├── logger.js └── ws.js └── tsconfig.json /.applications: -------------------------------------------------------------------------------- 1 | ../NodeJS-Application 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": "latest" 10 | }, 11 | "globals": { 12 | "BigInt": true 13 | }, 14 | "rules": { 15 | "indent": [ 16 | "error", 17 | 2 18 | ], 19 | "linebreak-style": [ 20 | "error", 21 | "unix" 22 | ], 23 | "quotes": [ 24 | "error", 25 | "single" 26 | ], 27 | "semi": [ 28 | "error", 29 | "always" 30 | ], 31 | "no-loop-func": [ 32 | "error" 33 | ], 34 | "block-spacing": [ 35 | "error", 36 | "always" 37 | ], 38 | "camelcase": [ 39 | "error" 40 | ], 41 | "eqeqeq": [ 42 | "error", 43 | "always" 44 | ], 45 | "strict": [ 46 | "error", 47 | "global" 48 | ], 49 | "brace-style": [ 50 | "error", 51 | "1tbs", 52 | { 53 | "allowSingleLine": true 54 | } 55 | ], 56 | "comma-style": [ 57 | "error", 58 | "last" 59 | ], 60 | "comma-spacing": [ 61 | "error", 62 | { 63 | "before": false, 64 | "after": true 65 | } 66 | ], 67 | "eol-last": [ 68 | "error" 69 | ], 70 | "func-call-spacing": [ 71 | "error", 72 | "never" 73 | ], 74 | "key-spacing": [ 75 | "error", 76 | { 77 | "beforeColon": false, 78 | "afterColon": true, 79 | "mode": "minimum" 80 | } 81 | ], 82 | "keyword-spacing": [ 83 | "error", 84 | { 85 | "before": true, 86 | "after": true, 87 | "overrides": { 88 | "function": { 89 | "after": false 90 | } 91 | } 92 | } 93 | ], 94 | "max-len": [ 95 | "error", 96 | { 97 | "code": 80, 98 | "ignoreUrls": true 99 | } 100 | ], 101 | "max-nested-callbacks": [ 102 | "error", 103 | { 104 | "max": 7 105 | } 106 | ], 107 | "new-cap": [ 108 | "error", 109 | { 110 | "newIsCap": true, 111 | "capIsNew": false, 112 | "properties": true 113 | } 114 | ], 115 | "new-parens": [ 116 | "error" 117 | ], 118 | "no-lonely-if": [ 119 | "error" 120 | ], 121 | "no-trailing-spaces": [ 122 | "error" 123 | ], 124 | "no-unneeded-ternary": [ 125 | "error" 126 | ], 127 | "no-whitespace-before-property": [ 128 | "error" 129 | ], 130 | "object-curly-spacing": [ 131 | "error", 132 | "always" 133 | ], 134 | "operator-assignment": [ 135 | "error", 136 | "always" 137 | ], 138 | "operator-linebreak": [ 139 | "error", 140 | "after" 141 | ], 142 | "semi-spacing": [ 143 | "error", 144 | { 145 | "before": false, 146 | "after": true 147 | } 148 | ], 149 | "space-before-blocks": [ 150 | "error", 151 | "always" 152 | ], 153 | "space-before-function-paren": [ 154 | "error", 155 | { 156 | "anonymous": "never", 157 | "named": "never", 158 | "asyncArrow": "always" 159 | } 160 | ], 161 | "space-in-parens": [ 162 | "error", 163 | "never" 164 | ], 165 | "space-infix-ops": [ 166 | "error" 167 | ], 168 | "space-unary-ops": [ 169 | "error", 170 | { 171 | "words": true, 172 | "nonwords": false, 173 | "overrides": { 174 | "typeof": false 175 | } 176 | } 177 | ], 178 | "no-unreachable": [ 179 | "error" 180 | ], 181 | "no-global-assign": [ 182 | "error" 183 | ], 184 | "no-self-compare": [ 185 | "error" 186 | ], 187 | "no-unmodified-loop-condition": [ 188 | "error" 189 | ], 190 | "no-constant-condition": [ 191 | "error", 192 | { 193 | "checkLoops": false 194 | } 195 | ], 196 | "no-console": [ 197 | "off" 198 | ], 199 | "no-useless-concat": [ 200 | "error" 201 | ], 202 | "no-useless-escape": [ 203 | "error" 204 | ], 205 | "no-shadow-restricted-names": [ 206 | "error" 207 | ], 208 | "no-use-before-define": [ 209 | "error", 210 | { 211 | "functions": false 212 | } 213 | ], 214 | "arrow-parens": [ 215 | "error", 216 | "always" 217 | ], 218 | "arrow-body-style": [ 219 | "error", 220 | "as-needed" 221 | ], 222 | "arrow-spacing": [ 223 | "error" 224 | ], 225 | "no-confusing-arrow": [ 226 | "error", 227 | { 228 | "allowParens": true 229 | } 230 | ], 231 | "no-useless-computed-key": [ 232 | "error" 233 | ], 234 | "no-useless-rename": [ 235 | "error" 236 | ], 237 | "no-var": [ 238 | "error" 239 | ], 240 | "object-shorthand": [ 241 | "error", 242 | "always" 243 | ], 244 | "prefer-arrow-callback": [ 245 | "error" 246 | ], 247 | "prefer-const": [ 248 | "error" 249 | ], 250 | "prefer-numeric-literals": [ 251 | "error" 252 | ], 253 | "prefer-rest-params": [ 254 | "error" 255 | ], 256 | "prefer-spread": [ 257 | "error" 258 | ], 259 | "rest-spread-spacing": [ 260 | "error", 261 | "never" 262 | ], 263 | "template-curly-spacing": [ 264 | "error", 265 | "never" 266 | ] 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | *.done 4 | *.pem 5 | .DS_Store 6 | data/ 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "overrides": [ 5 | { 6 | "files": ["**/.*rc"], 7 | "options": { "parser": "json" } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 HowProgrammingWorks 4 | Copyright (c) 2017-2023 Metarhia contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fastify + NodeJS application server 2 | -------------------------------------------------------------------------------- /lib/common.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace common { 2 | function hashPassword(password: string): Promise; 3 | function validatePassword( 4 | password: string, 5 | serHash: string, 6 | ): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('node:crypto'); 4 | 5 | const SCRYPT_PARAMS = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; 6 | const SCRYPT_PREFIX = '$scrypt$N=32768,r=8,p=1,maxmem=67108864$'; 7 | 8 | const serializeHash = (hash, salt) => { 9 | const saltString = salt.toString('base64').split('=')[0]; 10 | const hashString = hash.toString('base64').split('=')[0]; 11 | return `${SCRYPT_PREFIX}${saltString}$${hashString}`; 12 | }; 13 | 14 | const parseOptions = (options) => { 15 | const values = []; 16 | const items = options.split(','); 17 | for (const item of items) { 18 | const [key, val] = item.split('='); 19 | values.push([key, Number(val)]); 20 | } 21 | return Object.fromEntries(values); 22 | }; 23 | 24 | const deserializeHash = (phcString) => { 25 | const [, name, options, salt64, hash64] = phcString.split('$'); 26 | if (name !== 'scrypt') { 27 | throw new Error('Node.js crypto module only supports scrypt'); 28 | } 29 | const params = parseOptions(options); 30 | const salt = Buffer.from(salt64, 'base64'); 31 | const hash = Buffer.from(hash64, 'base64'); 32 | return { params, salt, hash }; 33 | }; 34 | 35 | const SALT_LEN = 32; 36 | const KEY_LEN = 64; 37 | 38 | const hashPassword = (password) => 39 | new Promise((resolve, reject) => { 40 | crypto.randomBytes(SALT_LEN, (err, salt) => { 41 | if (err) { 42 | reject(err); 43 | return; 44 | } 45 | crypto.scrypt(password, salt, KEY_LEN, SCRYPT_PARAMS, (err, hash) => { 46 | if (err) { 47 | reject(err); 48 | return; 49 | } 50 | resolve(serializeHash(hash, salt)); 51 | }); 52 | }); 53 | }); 54 | 55 | const validatePassword = (password, serHash) => { 56 | const { params, salt, hash } = deserializeHash(serHash); 57 | return new Promise((resolve, reject) => { 58 | const callback = (err, hashedPassword) => { 59 | if (err) { 60 | reject(err); 61 | return; 62 | } 63 | resolve(crypto.timingSafeEqual(hashedPassword, hash)); 64 | }; 65 | crypto.scrypt(password, salt, hash.length, params, callback); 66 | }); 67 | }; 68 | 69 | module.exports = { 70 | hashPassword, 71 | validatePassword, 72 | }; 73 | -------------------------------------------------------------------------------- /lib/db.d.ts: -------------------------------------------------------------------------------- 1 | type QueryResult = Promise; 2 | 3 | function deleteRecord(id: number): QueryResult; 4 | 5 | declare namespace db { 6 | export function query(sql: string, args: Array): QueryResult; 7 | export function read(id: number, fields: Array): QueryResult; 8 | export function create(record: object): QueryResult; 9 | export function update(id: number, record: object): QueryResult; 10 | export { deleteRecord as delete }; 11 | } 12 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pg = require('pg'); 4 | 5 | const crud = (pool) => (table) => ({ 6 | async query(sql, args) { 7 | const result = await pool.query(sql, args); 8 | return result.rows; 9 | }, 10 | 11 | async read(id, fields = ['*']) { 12 | const names = fields.join(', '); 13 | const sql = `SELECT ${names} FROM ${table}`; 14 | if (!id) return pool.query(sql); 15 | return pool.query(`${sql} WHERE id = $1`, [id]); 16 | }, 17 | 18 | async create({ ...record }) { 19 | const keys = Object.keys(record); 20 | const nums = new Array(keys.length); 21 | const data = new Array(keys.length); 22 | let i = 0; 23 | for (const key of keys) { 24 | data[i] = record[key]; 25 | nums[i] = `$${++i}`; 26 | } 27 | const fields = '"' + keys.join('", "') + '"'; 28 | const params = nums.join(', '); 29 | const sql = `INSERT INTO "${table}" (${fields}) VALUES (${params})`; 30 | return pool.query(sql, data); 31 | }, 32 | 33 | async update(id, { ...record }) { 34 | const keys = Object.keys(record); 35 | const updates = new Array(keys.length); 36 | const data = new Array(keys.length); 37 | let i = 0; 38 | for (const key of keys) { 39 | data[i] = record[key]; 40 | updates[i] = `${key} = $${++i}`; 41 | } 42 | const delta = updates.join(', '); 43 | const sql = `UPDATE ${table} SET ${delta} WHERE id = $${++i}`; 44 | data.push(id); 45 | return pool.query(sql, data); 46 | }, 47 | 48 | async delete(id) { 49 | const sql = 'DELETE FROM ${table} WHERE id = $1'; 50 | return pool.query(sql, [id]); 51 | }, 52 | }); 53 | 54 | module.exports = (options) => crud(new pg.Pool(options)); 55 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metatech-university/NodeJS-Fastify/b1ed297c8559970fa13e1acfa4b33b481b0fce41/log/.gitkeep -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fastify = require('fastify'); 4 | 5 | const path = require('node:path'); 6 | 7 | const { Logger, StreamForLogger } = require('./src/logger.js'); 8 | const http = require('./src/http.js'); 9 | const ws = require('./src/ws.js'); 10 | const { loadApplication } = require('./src/loader.js'); 11 | 12 | const APPLICATION_PATH = path.join(process.cwd(), '../NodeJS-Application'); 13 | const LOG_FOLDER_PATH = './log'; 14 | 15 | (async () => { 16 | const streamForLogger = new StreamForLogger(LOG_FOLDER_PATH); 17 | const server = fastify({ 18 | logger: { level: 'info', stream: streamForLogger }, 19 | }); 20 | const logger = new Logger(server.log); 21 | const app = await loadApplication(APPLICATION_PATH, logger); 22 | 23 | http.init(server, app.api); 24 | http.initStatic(server, APPLICATION_PATH); 25 | ws.init(server, app.api); 26 | http.start(server, { port: app.config.server.ports[0] }); 27 | })(); 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-fastify", 3 | "version": "1.0.0", 4 | "description": "Fastify powered NodeJS application runtime", 5 | "license": "MIT", 6 | "private": true, 7 | "main": "main.js", 8 | "scripts": { 9 | "test": "npm run lint && tsc", 10 | "lint": "eslint . && prettier -c \"**/*.js\" \"**/*.ts\"", 11 | "fmt": "prettier --write \"**/*.js\" \"**/*.ts\"" 12 | }, 13 | "engines": { 14 | "node": "16 || 18 || 19 || 20" 15 | }, 16 | "dependencies": { 17 | "@fastify/static": "^6.10.2", 18 | "@fastify/websocket": "^8.2.0", 19 | "fastify": "^4.20.0", 20 | "pg": "^8.11.1", 21 | "ws": "^8.12.0" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^20.4.4", 25 | "@types/pg": "^8.10.2", 26 | "@types/ws": "^8.5.5", 27 | "eslint": "^8.45.0", 28 | "eslint-config-prettier": "^8.6.0", 29 | "eslint-plugin-import": "^2.23.4", 30 | "eslint-plugin-prettier": "^5.0.0", 31 | "prettier": "^3.0.0", 32 | "typescript": "^5.1.6" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/http.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('node:path'); 4 | 5 | const fastigyStatic = require('@fastify/static'); 6 | 7 | function init(server, routes) { 8 | /* TODO: session support */ 9 | for (const [iface, methods] of Object.entries(routes)) { 10 | for (const [method, handler] of Object.entries(methods)) { 11 | if (typeof handler !== 'function') { 12 | continue; 13 | } 14 | 15 | server.post(`/api/${iface}/${method}`, async (request) => { 16 | const { query, body, headers } = request; 17 | const response = await handler({ ...query, ...body, headers }); 18 | return response; 19 | }); 20 | } 21 | } 22 | } 23 | 24 | function initStatic(server, appPath) { 25 | const staticPath = path.join(appPath, 'static'); 26 | 27 | server.register(fastigyStatic, { 28 | root: staticPath, 29 | wildcard: true, 30 | }); 31 | } 32 | 33 | const start = async (server, config) => { 34 | await server.listen(config); 35 | server.log.info(`API on port ${config.port}`); 36 | }; 37 | 38 | module.exports = { 39 | init, 40 | initStatic, 41 | start, 42 | }; 43 | -------------------------------------------------------------------------------- /src/loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fsp = require('node:fs').promises; 4 | const vm = require('node:vm'); 5 | const path = require('node:path'); 6 | 7 | const dbBuilder = require('../lib/db.js'); 8 | const common = require('../lib/common.js'); 9 | 10 | const OPTIONS = { 11 | timeout: 5000, 12 | displayErrors: false, 13 | }; 14 | 15 | const load = async (filePath, sandbox) => { 16 | const src = await fsp.readFile(filePath, 'utf8'); 17 | const code = `'use strict';\n{\n${src}\n}`; 18 | const script = new vm.Script(code, { ...OPTIONS, lineOffset: -2 }); 19 | const context = vm.createContext(Object.freeze({ ...sandbox })); 20 | const exported = script.runInContext(context, OPTIONS); 21 | return exported; 22 | }; 23 | 24 | const loadDir = async (dir, sandbox) => { 25 | const files = await fsp.readdir(dir); 26 | const container = {}; 27 | for (const fileName of files) { 28 | if (!fileName.endsWith('.js')) continue; 29 | const filePath = path.join(dir, fileName); 30 | const name = path.basename(fileName, '.js'); 31 | container[name] = await load(filePath, sandbox); 32 | } 33 | return container; 34 | }; 35 | 36 | const loadApplication = async (appPath, logger) => { 37 | const sandbox = { 38 | console: Object.freeze(logger), 39 | common: Object.freeze(common), 40 | }; 41 | 42 | const apiPath = path.join(appPath, './api'); 43 | const configPath = path.join(appPath, './config'); 44 | 45 | const config = await loadDir(configPath, sandbox); 46 | sandbox.config = Object.freeze(config); 47 | 48 | const db = dbBuilder(config.db); 49 | sandbox.db = Object.freeze(db); 50 | 51 | const api = await loadDir(apiPath, sandbox); 52 | sandbox.api = Object.freeze(api); 53 | 54 | return sandbox; 55 | }; 56 | 57 | module.exports = { 58 | load, 59 | loadDir, 60 | loadApplication, 61 | }; 62 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { util } = require('node:util'); 4 | const fs = require('node:fs'); 5 | const path = require('node:path'); 6 | 7 | class Logger { 8 | constructor(logger) { 9 | this.logger = logger; 10 | } 11 | 12 | log(data, ...args) { 13 | const msg = args.length <= 1 ? args[0] : args; 14 | this.logger.info(data, msg); 15 | } 16 | 17 | dir(...args) { 18 | const msg = util.inspect(...args); 19 | this.logger.info(msg); 20 | } 21 | 22 | debug(data, ...args) { 23 | const msg = args.length <= 1 ? args[0] : args; 24 | this.logger.debug(data, msg); 25 | } 26 | 27 | error(data, ...args) { 28 | const msg = args.length <= 1 ? args[0] : args; 29 | this.logger.error(data, msg); 30 | } 31 | 32 | system(...args) { 33 | const msg = util.format(...args); 34 | this.logger.info(msg); 35 | } 36 | 37 | access(...args) { 38 | const msg = util.format(...args); 39 | this.logger.info(msg); 40 | } 41 | } 42 | 43 | class StreamForLogger { 44 | #logFileStream; 45 | folderPath; 46 | date; 47 | 48 | constructor(folderPath) { 49 | this.folderPath = folderPath; 50 | this.date = new Date().toISOString().substring(0, 10); 51 | this.#createFileStream(); 52 | } 53 | 54 | write(msg) { 55 | process.stdout.write(msg); 56 | const currentDate = new Date().toISOString().substring(0, 10); 57 | if (currentDate !== this.date) { 58 | this.date = currentDate; 59 | this.#createFileStream(); 60 | } 61 | this.#logFileStream.write(msg); 62 | } 63 | 64 | #createFileStream() { 65 | if (this.#logFileStream) { 66 | this.#logFileStream.end(); 67 | } 68 | const filePath = path.join(this.folderPath, `${this.date}.log`); 69 | this.#logFileStream = fs.createWriteStream(filePath, { flags: 'a' }); 70 | return this.#logFileStream; 71 | } 72 | } 73 | 74 | module.exports = { 75 | Logger, 76 | StreamForLogger, 77 | }; 78 | -------------------------------------------------------------------------------- /src/ws.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const websocket = require('@fastify/websocket'); 4 | 5 | function init(server, routes) { 6 | server.register(websocket); 7 | server.register(async (server) => { 8 | server.get( 9 | '/api', 10 | { websocket: true }, 11 | (connection /* SocketStream */ /* req: FastifyRequest */) => { 12 | connection.socket.on('message', async (message) => { 13 | try { 14 | const { name, method, args = [] } = JSON.parse(message); 15 | const handler = routes?.[name]?.[method]; 16 | 17 | if (!handler) 18 | return connection.send('"Not found"', { binary: false }); 19 | 20 | const result = await handler(...args); 21 | connection.send(JSON.stringify(result), { binary: false }); 22 | } catch (err) { 23 | server.log.error(err); 24 | connection.send('"Server error"', { binary: false }); 25 | } 26 | }); 27 | }, 28 | ); 29 | }); 30 | } 31 | 32 | module.exports = { init }; 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "moduleResolution": "node", 5 | "strict": true, 6 | "baseUrl": ".", 7 | "preserveWatchOutput": true, 8 | "allowJs": true, 9 | "noEmit": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": ["*", "**/*"] 13 | } 14 | --------------------------------------------------------------------------------