├── log └── .gitkeep ├── .gitattributes ├── application ├── schemas │ └── .gitkeep ├── api │ ├── example │ │ ├── getUndefined.js │ │ ├── error.js │ │ ├── exception.js │ │ ├── wait.js │ │ ├── counter.js │ │ ├── webHook.js │ │ ├── remoteMethod.js │ │ ├── countries.js │ │ ├── subscribe.js │ │ ├── citiesByCountry.js │ │ ├── uploadFile.js │ │ └── resources.js │ ├── system │ │ └── introspect.js │ ├── auth │ │ ├── status.js │ │ ├── register.js │ │ └── signIn.js │ ├── cms │ │ ├── .eslintrc.json │ │ └── about.js │ └── .eslintrc.json ├── config │ ├── resmon.js │ ├── .eslintrc.json │ ├── database.js │ └── server.js ├── lib │ ├── pg │ │ ├── constants.js │ │ ├── updates.js │ │ ├── where.js │ │ └── Database.js │ ├── example │ │ ├── stop.js │ │ ├── doSomething.js │ │ ├── submodule1 │ │ │ ├── method1.js │ │ │ └── method2.js │ │ ├── submodule2 │ │ │ ├── method1.js │ │ │ ├── method2.js │ │ │ └── nested1 │ │ │ │ └── method1.js │ │ ├── submodule3 │ │ │ └── nested2 │ │ │ │ └── method1.js │ │ ├── add.js │ │ ├── storage │ │ │ └── set.js │ │ ├── start.js │ │ └── cache.js │ ├── .eslintrc.json │ ├── resmon │ │ ├── getStatistics.js │ │ └── start.js │ └── utils │ │ └── bytesToSize.js ├── db │ ├── install.sql │ ├── data.sql │ └── structure.sql ├── static │ ├── favicon.ico │ ├── favicon.png │ ├── metarhia.png │ ├── .eslintrc.json │ ├── metarhia.svg │ ├── manifest.json │ ├── index.html │ ├── console.css │ ├── metacom.js │ └── console.js ├── domain │ ├── database │ │ └── start.js │ └── .eslintrc.json ├── cert │ ├── generate.sh │ └── generate.ext └── .eslintrc.json ├── .eslintignore ├── .github └── FUNDING.yml ├── .gitignore ├── test ├── all.js ├── unit.js ├── unit.config.js ├── unit.logger.js ├── unit.security.js ├── unit.database.js └── system.js ├── .prettierrc ├── .editorconfig ├── .travis.yml ├── lib ├── common.js ├── config.js ├── dependencies.js ├── logger.js ├── security.js ├── database.js ├── server.js ├── auth.js ├── channel.js └── application.js ├── LICENSE ├── package.json ├── server.js ├── README.md └── .eslintrc.json /log/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text 2 | -------------------------------------------------------------------------------- /application/schemas/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: tshemsedinov 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | *.pem 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /application/api/example/getUndefined.js: -------------------------------------------------------------------------------- 1 | async () => undefined; 2 | -------------------------------------------------------------------------------- /application/config/resmon.js: -------------------------------------------------------------------------------- 1 | ({ 2 | interval: 30000, 3 | }); 4 | -------------------------------------------------------------------------------- /application/api/example/error.js: -------------------------------------------------------------------------------- 1 | async () => new Error('Hello!', 54321); 2 | -------------------------------------------------------------------------------- /test/all.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./unit.js'); 4 | require('./system.js'); 5 | -------------------------------------------------------------------------------- /application/lib/pg/constants.js: -------------------------------------------------------------------------------- 1 | ({ 2 | OPERATORS: ['>=', '<=', '<>', '>', '<'], 3 | }); 4 | -------------------------------------------------------------------------------- /application/api/example/exception.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | throw new Error('Hello', 12345); 3 | }; 4 | -------------------------------------------------------------------------------- /application/lib/example/stop.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | console.debug('Stop example plugin'); 3 | }; 4 | -------------------------------------------------------------------------------- /application/config/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "strict": ["error", "never"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /application/api/system/introspect.js: -------------------------------------------------------------------------------- 1 | ({ 2 | access: 'public', 3 | method: application.introspect, 4 | }); 5 | -------------------------------------------------------------------------------- /application/lib/example/doSomething.js: -------------------------------------------------------------------------------- 1 | () => { 2 | console.debug('Call method: example.doSomething'); 3 | }; 4 | -------------------------------------------------------------------------------- /application/db/install.sql: -------------------------------------------------------------------------------- 1 | CREATE USER marcus WITH PASSWORD 'marcus'; 2 | CREATE DATABASE application OWNER marcus; 3 | -------------------------------------------------------------------------------- /application/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedi4t/NodejsStarterKit/HEAD/application/static/favicon.ico -------------------------------------------------------------------------------- /application/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedi4t/NodejsStarterKit/HEAD/application/static/favicon.png -------------------------------------------------------------------------------- /application/static/metarhia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tedi4t/NodejsStarterKit/HEAD/application/static/metarhia.png -------------------------------------------------------------------------------- /application/lib/example/submodule1/method1.js: -------------------------------------------------------------------------------- 1 | () => { 2 | console.debug('Call method: example.submodule1.method1'); 3 | }; 4 | -------------------------------------------------------------------------------- /application/lib/example/submodule1/method2.js: -------------------------------------------------------------------------------- 1 | () => { 2 | console.debug('Call method: example.submodule1.method2'); 3 | }; 4 | -------------------------------------------------------------------------------- /application/lib/example/submodule2/method1.js: -------------------------------------------------------------------------------- 1 | () => { 2 | console.debug('Call method: example.submodule2.method1'); 3 | }; 4 | -------------------------------------------------------------------------------- /application/lib/example/submodule2/method2.js: -------------------------------------------------------------------------------- 1 | () => { 2 | console.debug('Call method: example.submodule2.method2'); 3 | }; 4 | -------------------------------------------------------------------------------- /application/api/example/wait.js: -------------------------------------------------------------------------------- 1 | async ({ delay }) => 2 | new Promise((resolve) => { 3 | setTimeout(resolve, delay, 'done'); 4 | }); 5 | -------------------------------------------------------------------------------- /application/lib/example/submodule2/nested1/method1.js: -------------------------------------------------------------------------------- 1 | () => { 2 | console.debug('Call method: example.submodule2.nested1.method1'); 3 | }; 4 | -------------------------------------------------------------------------------- /application/lib/example/submodule3/nested2/method1.js: -------------------------------------------------------------------------------- 1 | () => { 2 | console.debug('Call method: example.submodule3.nested2.method1'); 3 | }; 4 | -------------------------------------------------------------------------------- /application/api/auth/status.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | const status = context.token ? 'logged' : 'not logged'; 3 | return { result: status }; 4 | }; 5 | -------------------------------------------------------------------------------- /application/static/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "sourceType": "module" 4 | }, 5 | "rules": { 6 | "id-denylist": [0] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /application/config/database.js: -------------------------------------------------------------------------------- 1 | ({ 2 | host: '127.0.0.1', 3 | port: 5432, 4 | database: 'application', 5 | user: 'marcus', 6 | password: 'marcus', 7 | }); 8 | -------------------------------------------------------------------------------- /application/domain/database/start.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | console.debug('Connect to pg'); 3 | domain.database.example = new lib.pg.Database(config.database); 4 | }; 5 | -------------------------------------------------------------------------------- /application/api/example/counter.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | if (!context.counter) context.counter = 1; 3 | else context.counter++; 4 | return { result: context.counter }; 5 | }; 6 | -------------------------------------------------------------------------------- /application/api/example/webHook.js: -------------------------------------------------------------------------------- 1 | ({ 2 | access: 'public', 3 | method: async ({ ...args }) => { 4 | console.debug({ webHook: args }); 5 | return { result: 'success' }; 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /application/api/example/remoteMethod.js: -------------------------------------------------------------------------------- 1 | ({ 2 | access: 'public', 3 | method: async ({ ...args }) => { 4 | console.debug({ remoteMethod: args }); 5 | return { result: 'success' }; 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /application/api/example/countries.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | const fields = ['Id', 'Name']; 3 | const data = await domain.database.example.select('Country', fields); 4 | return { result: 'success', data }; 5 | }; 6 | -------------------------------------------------------------------------------- /test/unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const tests = ['config', 'logger', 'database', 'security']; 4 | 5 | for (const name of tests) { 6 | console.log(`Test unit: ${name}`); 7 | require(`./unit.${name}.js`); 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "overrides": [ 5 | { 6 | "files": ["**/.*rc", "**/*.json"], 7 | "options": { "parser": "json" } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /application/api/cms/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "strict": ["error", "never"] 4 | }, 5 | "globals": { 6 | "application": "readonly", 7 | "context": "readonly", 8 | "api": "readonly" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /application/lib/example/add.js: -------------------------------------------------------------------------------- 1 | ({ 2 | parameters: { 3 | a: 'number', 4 | b: 'number', 5 | }, 6 | 7 | method({ a, b }) { 8 | console.log({ a, b }); 9 | return a + b; 10 | }, 11 | 12 | returns: 'number', 13 | }); 14 | -------------------------------------------------------------------------------- /application/api/auth/register.js: -------------------------------------------------------------------------------- 1 | async ({ login, password, fullName }) => { 2 | const hash = await application.security.hashPassword(password); 3 | await application.auth.registerUser(login, hash, fullName); 4 | return { result: 'success' }; 5 | }; 6 | -------------------------------------------------------------------------------- /application/api/example/subscribe.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | setInterval(() => { 3 | const stats = lib.resmon.getStatistics(); 4 | context.client.emit('example/resmon', stats); 5 | }, config.resmon.interval); 6 | return { subscribed: 'resmon' }; 7 | }; 8 | -------------------------------------------------------------------------------- /application/config/server.js: -------------------------------------------------------------------------------- 1 | ({ 2 | host: '127.0.0.1', 3 | balancer: 8000, 4 | protocol: 'http', 5 | ports: [8001, 8002], 6 | timeout: 5000, 7 | concurrency: 1000, 8 | queue: { 9 | size: 2000, 10 | timeout: 3000, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /application/api/example/citiesByCountry.js: -------------------------------------------------------------------------------- 1 | async ({ countryId }) => { 2 | const fields = ['Id', 'Name']; 3 | const where = { countryId }; 4 | const data = await domain.database.example.select('City', fields, where); 5 | return { result: 'success', data }; 6 | }; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [{*.js,*.mjs,*.ts,*.json,*.yml}] 11 | indent_size = 2 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /application/api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "application": "readonly", 4 | "config": "readonly", 5 | "context": "readonly", 6 | "node": "readonly", 7 | "npm": "readonly", 8 | "lib": "readonly", 9 | "domain": "readonly" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /application/cert/generate.sh: -------------------------------------------------------------------------------- 1 | cd "$(dirname "$0")" 2 | openssl genrsa -out key.pem 3072 3 | openssl req -new -out self.pem -key key.pem -subj '/CN=localhost' 4 | openssl req -text -noout -in self.pem 5 | openssl x509 -req -days 1024 -in self.pem -signkey key.pem -out cert.pem -extfile generate.ext 6 | -------------------------------------------------------------------------------- /application/cert/generate.ext: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = @alt_names 5 | 6 | [alt_names] 7 | DNS.1 = example.com 8 | IP.1 = 127.0.0.1 9 | IP.2 = ::1 10 | -------------------------------------------------------------------------------- /application/lib/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "strict": ["error", "never"] 4 | }, 5 | "globals": { 6 | "application": "readonly", 7 | "config": "readonly", 8 | "node": "readonly", 9 | "npm": "readonly", 10 | "lib": "readonly", 11 | "domain": "readonly" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /application/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "sourceType": "module" 4 | }, 5 | "rules": { 6 | "id-denylist": [ 7 | 2, 8 | "application", 9 | "node", 10 | "npm", 11 | "api", 12 | "lib", 13 | "domain", 14 | "config" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /application/domain/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "strict": ["error", "never"] 4 | }, 5 | "globals": { 6 | "application": "readonly", 7 | "config": "readonly", 8 | "node": "readonly", 9 | "npm": "readonly", 10 | "lib": "readonly", 11 | "domain": "readonly" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /application/lib/example/storage/set.js: -------------------------------------------------------------------------------- 1 | ({ 2 | values: new Map(), 3 | 4 | method({ key, val }) { 5 | console.log({ key, val }); 6 | if (val) { 7 | return this.values.set(key, val); 8 | } 9 | const res = this.values.get(key); 10 | console.log({ return: { res } }); 11 | return res; 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /application/lib/resmon/getStatistics.js: -------------------------------------------------------------------------------- 1 | () => { 2 | const { heapTotal, heapUsed, external } = node.process.memoryUsage(); 3 | const hs = node.v8.getHeapStatistics(); 4 | const contexts = hs.number_of_native_contexts; 5 | const detached = hs.number_of_detached_contexts; 6 | return { heapTotal, heapUsed, external, contexts, detached }; 7 | }; 8 | -------------------------------------------------------------------------------- /application/lib/example/start.js: -------------------------------------------------------------------------------- 1 | ({ 2 | privateField: 100, 3 | 4 | async method() { 5 | console.log('Start example plugin'); 6 | this.parent.cache.set({ key: 'keyName', val: this.privateField }); 7 | const res = lib.example.cache.get({ key: 'keyName' }); 8 | console.log({ res, cache: this.parent.cache.values }); 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /application/api/example/uploadFile.js: -------------------------------------------------------------------------------- 1 | async ({ name, data }) => { 2 | const buffer = Buffer.from(data, 'base64'); 3 | const tmpPath = 'application/tmp'; 4 | const filePath = node.path.join(tmpPath, name); 5 | if (filePath.startsWith(tmpPath)) { 6 | await node.fsp.writeFile(filePath, buffer); 7 | } 8 | return { uploaded: data.length }; 9 | }; 10 | -------------------------------------------------------------------------------- /application/lib/example/cache.js: -------------------------------------------------------------------------------- 1 | ({ 2 | values: new Map(), 3 | 4 | set({ key, val }) { 5 | console.log({ set: { key, val } }); 6 | return this.values.set(key, val); 7 | }, 8 | 9 | get({ key }) { 10 | console.log({ get: key }); 11 | const res = this.values.get(key); 12 | console.log({ return: res }); 13 | return res; 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /application/lib/utils/bytesToSize.js: -------------------------------------------------------------------------------- 1 | (bytes) => { 2 | const UNITS = ['', ' Kb', ' Mb', ' Gb', ' Tb', ' Pb', ' Eb', ' Zb', ' Yb']; 3 | if (bytes === 0) return '0'; 4 | const exp = Math.floor(Math.log(bytes) / Math.log(1000)); 5 | const size = bytes / 1000 ** exp; 6 | const short = Math.round(size, 2); 7 | const unit = UNITS[exp]; 8 | return short + unit; 9 | }; 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 16 4 | - 18 5 | - 19 6 | services: 7 | - postgresql 8 | before_script: 9 | - psql -f application/db/install.sql -U postgres 10 | - PGPASSWORD=marcus psql -d application -f application/db/structure.sql -U marcus 11 | - PGPASSWORD=marcus psql -d application -f application/db/data.sql -U marcus 12 | script: 13 | - npm test 14 | -------------------------------------------------------------------------------- /application/lib/pg/updates.js: -------------------------------------------------------------------------------- 1 | (delta, firstArgIndex = 1) => { 2 | const clause = []; 3 | const args = []; 4 | let i = firstArgIndex; 5 | const keys = Object.keys(delta); 6 | for (const key of keys) { 7 | const value = delta[key].toString(); 8 | clause.push(`${key} = $${i++}`); 9 | args.push(value); 10 | } 11 | return { clause: clause.join(', '), args }; 12 | }; 13 | -------------------------------------------------------------------------------- /application/static/metarhia.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Metarhia 4 | ultimate automation 5 | -------------------------------------------------------------------------------- /application/api/example/resources.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | const loadavg = node.os.loadavg(); 3 | const stats = lib.resmon.getStatistics(); 4 | const { heapTotal, heapUsed, external, contexts } = stats; 5 | const total = lib.utils.bytesToSize(heapTotal); 6 | const used = lib.utils.bytesToSize(heapUsed); 7 | const ext = lib.utils.bytesToSize(external); 8 | return { total, used, ext, contexts, loadavg }; 9 | }; 10 | -------------------------------------------------------------------------------- /test/unit.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const assert = require('assert').strict; 5 | 6 | const Config = require('../lib/config.js'); 7 | 8 | assert(Config); 9 | 10 | const PATH = process.cwd(); 11 | const configPath = path.join(PATH, 'application/config'); 12 | 13 | (async () => { 14 | const config = await new Config(configPath); 15 | assert(config); 16 | assert(config.database); 17 | assert(config.server); 18 | })(); 19 | -------------------------------------------------------------------------------- /application/api/auth/signIn.js: -------------------------------------------------------------------------------- 1 | ({ 2 | access: 'public', 3 | method: async ({ login, password }) => { 4 | const user = await application.auth.getUser(login); 5 | const hash = user ? user.password : undefined; 6 | const valid = await application.security.validatePassword(password, hash); 7 | if (!user || !valid) throw new Error('Incorrect login or password'); 8 | console.log(`Logged user: ${login}`); 9 | return { result: 'success', userId: user.id }; 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /application/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Metarhia", 3 | "name": "Metarhia console", 4 | "icons": [ 5 | { 6 | "src": "/favicon.png", 7 | "type": "image/png", 8 | "sizes": "128x128" 9 | }, 10 | { 11 | "src": "/metarhia.png", 12 | "type": "image/png", 13 | "sizes": "256x256" 14 | } 15 | ], 16 | "start_url": "/", 17 | "background_color": "#552277", 18 | "display": "standalone", 19 | "scope": "/", 20 | "theme_color": "#009933" 21 | } 22 | -------------------------------------------------------------------------------- /application/lib/resmon/start.js: -------------------------------------------------------------------------------- 1 | async () => { 2 | if (config.resmon.active) { 3 | setInterval(() => { 4 | const stats = lib.resmon.getStatistics(); 5 | const { heapTotal, heapUsed, external, contexts, detached } = stats; 6 | const total = lib.utils.bytesToSize(heapTotal); 7 | const used = lib.utils.bytesToSize(heapUsed); 8 | const ext = lib.utils.bytesToSize(external); 9 | console.debug(`Heap: ${used} of ${total}, ext: ${ext}`); 10 | console.debug(`Contexts: ${contexts}, detached: ${detached}`); 11 | }, config.resmon.interval); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /test/unit.logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert').strict; 4 | const path = require('path'); 5 | 6 | const Logger = require('../lib/logger.js'); 7 | assert(Logger); 8 | 9 | const PATH = process.cwd(); 10 | const workerId = 1; 11 | const logPath = path.join(PATH, 'log'); 12 | const logger = new Logger(logPath, workerId); 13 | assert(logger); 14 | 15 | assert(logger.write); 16 | assert.equal(typeof logger.write, 'function'); 17 | 18 | assert(logger.log); 19 | assert.equal(typeof logger.log, 'function'); 20 | 21 | logger.log('Logger test: log method'); 22 | logger.dir('Logger test: dir method'); 23 | logger.debug('Logger test: debug method'); 24 | logger.error(new Error('Logger test: Example error')); 25 | -------------------------------------------------------------------------------- /test/unit.security.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert').strict; 4 | 5 | const security = require('../lib/security.js'); 6 | 7 | assert(security); 8 | 9 | const password = 'correct horse battery staple'; 10 | const wrongPassword = 'password'; 11 | 12 | security 13 | .hashPassword(password) 14 | .then((hash) => { 15 | assert(hash); 16 | assert.equal(typeof hash, 'string'); 17 | return Promise.all([ 18 | security.validatePassword(password, hash), 19 | security.validatePassword(wrongPassword, hash), 20 | ]); 21 | }) 22 | .then((result) => { 23 | assert.deepEqual(result, [true, false]); 24 | }) 25 | .catch((err) => { 26 | console.log(err.stack); 27 | process.exit(1); 28 | }); 29 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parseHost = (host) => { 4 | const portOffset = host.indexOf(':'); 5 | if (portOffset > -1) return host.substr(0, portOffset); 6 | return host; 7 | }; 8 | 9 | const timeout = (msec) => 10 | new Promise((resolve) => { 11 | setTimeout(resolve, msec); 12 | }); 13 | 14 | const sample = (arr) => arr[Math.floor(Math.random() * arr.length)]; 15 | 16 | const between = (s, prefix, suffix) => { 17 | let i = s.indexOf(prefix); 18 | if (i === -1) return ''; 19 | s = s.substring(i + prefix.length); 20 | if (suffix) { 21 | i = s.indexOf(suffix); 22 | if (i === -1) return ''; 23 | s = s.substring(0, i); 24 | } 25 | return s; 26 | }; 27 | 28 | module.exports = { parseHost, timeout, sample, between }; 29 | -------------------------------------------------------------------------------- /application/lib/pg/where.js: -------------------------------------------------------------------------------- 1 | (conditions, firstArgIndex = 1) => { 2 | const clause = []; 3 | const args = []; 4 | let i = firstArgIndex; 5 | const keys = Object.keys(conditions); 6 | for (const key of keys) { 7 | let operator = '='; 8 | let value = conditions[key]; 9 | if (typeof value === 'string') { 10 | for (const op of lib.pg.constants.OPERATORS) { 11 | const len = op.length; 12 | if (value.startsWith(op)) { 13 | operator = op; 14 | value = value.substring(len); 15 | } 16 | } 17 | if (value.includes('*') || value.includes('?')) { 18 | operator = 'LIKE'; 19 | value = value.replace(/\*/g, '%').replace(/\?/g, '_'); 20 | } 21 | } 22 | clause.push(`${key} ${operator} $${i++}`); 23 | args.push(value); 24 | } 25 | return { clause: clause.join(' AND '), args }; 26 | }; 27 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { path, fsp, vm } = require('./dependencies.js').node; 4 | 5 | const SCRIPT_OPTIONS = { timeout: 5000 }; 6 | 7 | class Config { 8 | constructor(configPath) { 9 | this.sections = {}; 10 | this.path = configPath; 11 | this.sandbox = vm.createContext({}); 12 | return this.load(); 13 | } 14 | 15 | async load() { 16 | const files = await fsp.readdir(this.path); 17 | for (const fileName of files) { 18 | await this.loadFile(fileName); 19 | } 20 | return this.sections; 21 | } 22 | 23 | async loadFile(fileName) { 24 | const { name, ext } = path.parse(fileName); 25 | if (ext !== '.js') return; 26 | const configFile = path.join(this.path, fileName); 27 | const code = await fsp.readFile(configFile, 'utf8'); 28 | const src = `'use strict';\n${code}`; 29 | const options = { filename: configFile, lineOffset: -1 }; 30 | const script = new vm.Script(src, options); 31 | const exports = script.runInContext(this.sandbox, SCRIPT_OPTIONS); 32 | this.sections[name] = exports; 33 | } 34 | } 35 | 36 | module.exports = Config; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2023 How.Programming.Works contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/dependencies.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const node = {}; 4 | const internals = [ 5 | 'util', 6 | 'child_process', 7 | 'worker_threads', 8 | 'os', 9 | 'v8', 10 | 'vm', 11 | 'path', 12 | 'url', 13 | 'assert', 14 | 'querystring', 15 | 'string_decoder', 16 | 'perf_hooks', 17 | 'async_hooks', 18 | 'timers', 19 | 'events', 20 | 'stream', 21 | 'fs', 22 | 'crypto', 23 | 'zlib', 24 | 'readline', 25 | 'dns', 26 | 'net', 27 | 'tls', 28 | 'http', 29 | 'https', 30 | 'http2', 31 | 'dgram', 32 | ]; 33 | 34 | for (const name of internals) node[name] = require(name); 35 | node.process = process; 36 | node.childProcess = node['child_process']; 37 | node.StringDecoder = node['string_decoder']; 38 | node.perfHooks = node['perf_hooks']; 39 | node.asyncHooks = node['async_hooks']; 40 | node.worker = node['worker_threads']; 41 | node.fsp = node.fs.promises; 42 | Object.freeze(node); 43 | 44 | const npm = { 45 | common: require('./common.js'), 46 | ws: require('ws'), 47 | }; 48 | 49 | const pkgPath = node.path.join(process.cwd(), 'package.json'); 50 | const pkg = require(pkgPath); 51 | 52 | if (pkg.dependencies) { 53 | for (const dependency of Object.keys(pkg.dependencies)) { 54 | if (dependency !== 'impress') npm[dependency] = require(dependency); 55 | } 56 | } 57 | Object.freeze(npm); 58 | 59 | module.exports = { node, npm }; 60 | -------------------------------------------------------------------------------- /application/api/cms/about.js: -------------------------------------------------------------------------------- 1 | async () => [ 2 | 'Metarhia is a Community and Technology Stack', 3 | 'for Distributed Highload Applications and Data Storage', 4 | '', 5 | 'Activities:', 6 | '• Academic fields: Research, Education and Open Lectures', 7 | '• Open Source Contribution e.g. Node.js, Impress, Metasync, etc.', 8 | '• Services and Products', 9 | '', 10 | 'Metarhia provides following services:', 11 | '• Software Development', 12 | '• Software Audit, Quality Control and Code Review', 13 | '• Business Processes Analysis', 14 | '• Architecture Solutions and Consulting', 15 | '• Database structure and technical specification', 16 | '• Project planning: time and cost estimation', 17 | '• Education, Training, Team building and Recruiting', 18 | '', 19 | 'Metarhia is a group of IT professionals, located in Kiev (Ukraine)', 20 | 'and working together in software development, internet solutions', 21 | 'and production automation. We are experienced in development and', 22 | 'system integration, ours architects are over 20 years in information', 23 | 'technologies. Ours software developers have practical knowledge in', 24 | 'programming including C, C++, JavaScript, Rust, Go, Swift, Java,', 25 | 'Objective-C, Kotlin, C#, Delphi, Assembler, Python, Haskell, etc.', 26 | 'We provide solutions for Unix/Linux, Windows, OSX, Android, Internet', 27 | 'solutions and Embedded systems.', 28 | ]; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejsstarterkit", 3 | "version": "2.0.2", 4 | "private": true, 5 | "description": "Node.js Starter Kit", 6 | "author": "Timur Shemsedinov ", 7 | "license": "MIT", 8 | "keywords": [ 9 | "server", 10 | "api", 11 | "rpc", 12 | "rest", 13 | "service", 14 | "web", 15 | "router", 16 | "routing", 17 | "cluster", 18 | "cache", 19 | "http", 20 | "https", 21 | "websocket", 22 | "websockets", 23 | "samdboxing", 24 | "isolation", 25 | "context", 26 | "framework", 27 | "scale", 28 | "scaling", 29 | "thread", 30 | "worker" 31 | ], 32 | "main": "server.js", 33 | "scripts": { 34 | "test": "npm run -s lint && node test/all.js", 35 | "lint": "eslint . --fix && prettier --write \"**/*.js\" \"**/*.json\"", 36 | "install": "application/cert/generate.sh" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/HowProgrammingWorks/NodejsStarterKit.git" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/HowProgrammingWorks/NodejsStarterKit/issues" 44 | }, 45 | "homepage": "https://github.com/HowProgrammingWorks/NodejsStarterKit", 46 | "engines": { 47 | "node": "16 || 18 || 19" 48 | }, 49 | "devDependencies": { 50 | "eslint": "^8.34.0", 51 | "prettier": "^2.8.4" 52 | }, 53 | "dependencies": { 54 | "pg": "^8.9.0", 55 | "ws": "^8.12.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /application/db/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO SystemUser (Login, Password) VALUES 2 | ('admin', '$scrypt$N=32768,r=8,p=1,maxmem=67108864$XcD5Zfk+BVIGEyiksBjjy9LL42AFOOqlhEB650woECs$3CNOs25gOVV8AZMYQc6bFnrYdM+3xP996shxJEq5LxGt4gs1g9cocZmi/SYg/H16egY4j7qxTD/oygyEI80cgg'), 3 | ('marcus', '$scrypt$N=32768,r=8,p=1,maxmem=67108864$aGKuH5D2zndi6zFu74/rEj5m3u5kRh5b+QXYfKrhAU8$257up1h/3R9CoxH2382zX0+kbxRPrd+GwzJIxYI+K+gBYCcLrA8Z6wv7lSwLElfbDTJRgUhQJFhMT1tpp5AJxw'), 4 | ('user', '$scrypt$N=32768,r=8,p=1,maxmem=67108864$z5uf2xGdpgq5v2sZbgh36QoZG9CDmGmJUNJkrs1zs2w$3s3x22k4Td0jkup4WduFQGFVjrFKHjN1WV9k8/Bh3DKI58Wrlo/D4Js9j/DiskwI8rltDd8pF15JylivFu2T0g'), 5 | ('iskandar', '$scrypt$N=32768,r=8,p=1,maxmem=67108864$EfcPTou2sTms7Esp4lsbJddN2RLAqqZUhP6sflwT7KU$FJiY0ad+qeNtZyFa0sXfQfeSIDS5HYS8wMk2/gtUlqy5vBddzVgKQYDqF5lKMNCm7IpOaYUZtRv7BQbxbVgkYg'); 6 | 7 | -- Examples login/password 8 | -- admin/123456 9 | -- marcus/marcus 10 | -- user/nopassword 11 | -- iskandar/zulqarnayn 12 | 13 | INSERT INTO SystemGroup (Name) VALUES 14 | ('admins'), 15 | ('users'), 16 | ('guests'); 17 | 18 | INSERT INTO GroupUser (GroupId, UserId) VALUES 19 | (1, 1), 20 | (2, 2), 21 | (2, 3), 22 | (2, 4); 23 | 24 | INSERT INTO Country (Name) VALUES 25 | ('Soviet Union'), 26 | ('People''s Republic of China'), 27 | ('Vietnam'), 28 | ('Cuba'); 29 | 30 | INSERT INTO City (Name, CountryId) VALUES 31 | ('Beijing', 2), 32 | ('Wuhan', 2), 33 | ('Kiev', 1), 34 | ('Havana', 4), 35 | ('Hanoi', 3), 36 | ('Kaliningrad', 1); 37 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { fs, util, path } = require('./dependencies.js').node; 4 | 5 | const COLORS = { 6 | info: '\x1b[1;37m', 7 | debug: '\x1b[1;33m', 8 | error: '\x1b[0;31m', 9 | system: '\x1b[1;34m', 10 | access: '\x1b[1;38m', 11 | }; 12 | 13 | const DATETIME_LENGTH = 19; 14 | 15 | class Logger { 16 | constructor(logPath) { 17 | this.path = logPath; 18 | const date = new Date().toISOString().substring(0, 10); 19 | const filePath = path.join(logPath, `${date}.log`); 20 | this.stream = fs.createWriteStream(filePath, { flags: 'a' }); 21 | this.home = path.dirname(this.path); 22 | } 23 | 24 | close() { 25 | return new Promise((resolve) => this.stream.end(resolve)); 26 | } 27 | 28 | write(level = 'info', s) { 29 | const now = new Date().toISOString(); 30 | const date = now.substring(0, DATETIME_LENGTH); 31 | const color = COLORS[level]; 32 | const line = date + '\t' + s; 33 | console.log(color + line + '\x1b[0m'); 34 | const out = line.replaceAll('\n', '; ') + '\n'; 35 | this.stream.write(out); 36 | } 37 | 38 | log(...args) { 39 | const msg = util.format(...args); 40 | this.write('info', msg); 41 | } 42 | 43 | dir(...args) { 44 | const msg = util.inspect(...args); 45 | this.write('info', msg); 46 | } 47 | 48 | debug(...args) { 49 | const msg = util.format(...args); 50 | this.write('debug', msg); 51 | } 52 | 53 | error(...args) { 54 | const msg = util.format(...args); 55 | this.write('error', msg.replaceAll(this.home, '')); 56 | } 57 | 58 | system(...args) { 59 | const msg = util.format(...args); 60 | this.write('system', msg); 61 | } 62 | 63 | access(...args) { 64 | const msg = util.format(...args); 65 | this.write('access', msg); 66 | } 67 | } 68 | 69 | module.exports = Logger; 70 | -------------------------------------------------------------------------------- /test/unit.database.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert').strict; 4 | const path = require('path'); 5 | 6 | const application = require('../lib/application.js'); 7 | application.logger = { log: console.log }; 8 | 9 | const Database = require('../lib/database.js'); 10 | assert(Database); 11 | 12 | const Config = require('../lib/config.js'); 13 | assert(Config); 14 | 15 | const PATH = process.cwd(); 16 | 17 | (async () => { 18 | const configPath = path.join(PATH, 'application/config'); 19 | const config = await new Config(configPath); 20 | const databaseConfig = config.database; 21 | const database = new Database(databaseConfig); 22 | 23 | const empty = 'empty'; 24 | try { 25 | const user = { login: empty, password: empty, fullName: empty }; 26 | const result = await database.insert('SystemUser', user); 27 | assert(result); 28 | assert.equal(result.rowCount, 1); 29 | } catch (err) { 30 | console.log(err.stack); 31 | process.exit(1); 32 | } 33 | try { 34 | const fields = ['login', 'password']; 35 | const cond = { login: empty }; 36 | const [record] = await database.select('SystemUser', fields, cond); 37 | assert.equal(record.login, empty); 38 | assert.equal(record.password, empty); 39 | } catch (err) { 40 | console.log(err.stack); 41 | process.exit(1); 42 | } 43 | try { 44 | const delta = { password: empty }; 45 | const cond = { login: empty }; 46 | const result = await database.update('SystemUser', delta, cond); 47 | assert.equal(result.rowCount, 1); 48 | } catch (err) { 49 | console.log(err.stack); 50 | process.exit(1); 51 | } 52 | try { 53 | const cond = { login: empty }; 54 | const result = await database.delete('SystemUser', cond); 55 | assert.equal(result.rowCount, 1); 56 | } catch (err) { 57 | console.log(err.stack); 58 | process.exit(1); 59 | } 60 | database.close(); 61 | })(); 62 | -------------------------------------------------------------------------------- /application/lib/pg/Database.js: -------------------------------------------------------------------------------- 1 | (class Database { 2 | constructor(options) { 3 | this.pool = new npm.pg.Pool(options); 4 | } 5 | 6 | query(sql, values) { 7 | const data = values ? values.join(',') : ''; 8 | console.debug(`${sql}\t[${data}]`); 9 | return this.pool.query(sql, values); 10 | } 11 | 12 | insert(table, record) { 13 | const keys = Object.keys(record); 14 | const nums = new Array(keys.length); 15 | const data = new Array(keys.length); 16 | let i = 0; 17 | for (const key of keys) { 18 | data[i] = record[key]; 19 | nums[i] = `$${++i}`; 20 | } 21 | const fields = keys.join(', '); 22 | const params = nums.join(', '); 23 | const sql = `INSERT INTO ${table} (${fields}) VALUES (${params})`; 24 | return this.query(sql, data); 25 | } 26 | 27 | async select(table, fields = ['*'], conditions = null) { 28 | const keys = fields.join(', '); 29 | const sql = `SELECT ${keys} FROM ${table}`; 30 | let whereClause = ''; 31 | let args = []; 32 | if (conditions) { 33 | const whereData = lib.pg.utils.where(conditions); 34 | whereClause = ' WHERE ' + whereData.clause; 35 | args = whereData.args; 36 | } 37 | const res = await this.query(sql + whereClause, args); 38 | return res.rows; 39 | } 40 | 41 | delete(table, conditions = null) { 42 | const { clause, args } = lib.pg.utils.where(conditions); 43 | const sql = `DELETE FROM ${table} WHERE ${clause}`; 44 | return this.query(sql, args); 45 | } 46 | 47 | update(table, delta = null, conditions = null) { 48 | const upd = lib.pg.utils.updates(delta); 49 | const cond = lib.pg.utils.where(conditions, upd.args.length + 1); 50 | const sql = `UPDATE ${table} SET ${upd.clause} WHERE ${cond.clause}`; 51 | const args = [...upd.args, ...cond.args]; 52 | return this.query(sql, args); 53 | } 54 | 55 | close() { 56 | this.pool.end(); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /test/system.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../server.js'); 4 | 5 | const http = require('http'); 6 | const assert = require('assert').strict; 7 | 8 | const HOST = '127.0.0.1'; 9 | const PORT = 8001; 10 | const START_DELAY = 2000; 11 | const TEST_DELAY = 100; 12 | const TEST_TIMEOUT = 3000; 13 | 14 | let callId = 0; 15 | 16 | console.log('System test started'); 17 | setTimeout(async () => { 18 | console.log('System test finished'); 19 | process.exit(0); 20 | }, TEST_TIMEOUT); 21 | 22 | const tasks = [ 23 | { get: '/', port: 8000, status: 302 }, 24 | { get: '/console.js' }, 25 | { 26 | post: '/api', 27 | method: 'auth/signIn', 28 | args: { login: 'marcus', password: 'marcus' }, 29 | }, 30 | ]; 31 | 32 | const getRequest = (task) => { 33 | const request = { 34 | host: HOST, 35 | port: task.port || PORT, 36 | agent: false, 37 | }; 38 | if (task.get) { 39 | request.method = 'GET'; 40 | request.path = task.get; 41 | } else if (task.post) { 42 | request.method = 'POST'; 43 | request.path = task.post; 44 | } 45 | if (task.args) { 46 | const packet = { call: ++callId, [task.method]: task.args }; 47 | task.data = JSON.stringify(packet); 48 | request.headers = { 49 | 'Content-Type': 'application/json', 50 | 'Content-Length': task.data.length, 51 | }; 52 | } 53 | return request; 54 | }; 55 | 56 | setTimeout(() => { 57 | tasks.forEach((task) => { 58 | const name = task.get || task.post; 59 | console.log('HTTP request ' + name); 60 | const request = getRequest(task); 61 | const req = http.request(request); 62 | req.on('response', (res) => { 63 | const expectedStatus = task.status || 200; 64 | setTimeout(() => { 65 | assert.equal(res.statusCode, expectedStatus); 66 | }, TEST_DELAY); 67 | }); 68 | req.on('error', (err) => { 69 | console.log(err.stack); 70 | }); 71 | if (task.data) req.write(task.data); 72 | req.end(); 73 | }); 74 | }, START_DELAY); 75 | -------------------------------------------------------------------------------- /application/db/structure.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE SystemUser ( 2 | Id serial, 3 | Login varchar(64) NOT NULL, 4 | Password varchar(255) NOT NULL, 5 | FullName varchar(255) 6 | ); 7 | 8 | ALTER TABLE SystemUser ADD CONSTRAINT pkSystemUser PRIMARY KEY (Id); 9 | 10 | CREATE UNIQUE INDEX akSystemUserLogin ON SystemUser (Login); 11 | 12 | CREATE TABLE SystemGroup ( 13 | Id serial, 14 | Name varchar(64) NOT NULL 15 | ); 16 | 17 | ALTER TABLE SystemGroup ADD CONSTRAINT pkSystemGroup PRIMARY KEY (Id); 18 | 19 | CREATE UNIQUE INDEX akSystemGroupName ON SystemGroup (Name); 20 | 21 | CREATE TABLE GroupUser ( 22 | GroupId integer NOT NULL, 23 | UserId integer NOT NULL 24 | ); 25 | 26 | ALTER TABLE GroupUser ADD CONSTRAINT pkGroupUser PRIMARY KEY (GroupId, UserId); 27 | ALTER TABLE GroupUser ADD CONSTRAINT fkGroupUserGroupId FOREIGN KEY (GroupId) REFERENCES SystemGroup (Id) ON DELETE CASCADE; 28 | ALTER TABLE GroupUser ADD CONSTRAINT fkGroupUserUserId FOREIGN KEY (UserId) REFERENCES SystemUser (Id) ON DELETE CASCADE; 29 | 30 | CREATE TABLE Session ( 31 | Id serial, 32 | UserId integer NOT NULL, 33 | Token varchar(64) NOT NULL, 34 | IP varchar(45) NOT NULL, 35 | Data text 36 | ); 37 | 38 | ALTER TABLE Session ADD CONSTRAINT pkSession PRIMARY KEY (Id); 39 | 40 | CREATE UNIQUE INDEX akSession ON Session (Token); 41 | 42 | ALTER TABLE Session ADD CONSTRAINT fkSessionUserId FOREIGN KEY (UserId) REFERENCES SystemUser (Id) ON DELETE CASCADE; 43 | 44 | CREATE TABLE Country ( 45 | Id serial, 46 | Name varchar(64) NOT NULL 47 | ); 48 | 49 | ALTER TABLE Country ADD CONSTRAINT pkCountry PRIMARY KEY (Id); 50 | 51 | CREATE UNIQUE INDEX akCountry ON Country (Name); 52 | 53 | CREATE TABLE City ( 54 | Id serial, 55 | Name varchar(64) NOT NULL, 56 | CountryId integer NOT NULL 57 | ); 58 | 59 | ALTER TABLE City ADD CONSTRAINT pkCity PRIMARY KEY (Id); 60 | 61 | CREATE UNIQUE INDEX akCity ON City (Name); 62 | 63 | ALTER TABLE City ADD CONSTRAINT fkCityCountryId FOREIGN KEY (CountryId) REFERENCES Country (Id) ON DELETE CASCADE; 64 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { node } = require('./lib/dependencies.js'); 4 | const { fsp, path } = node; 5 | const application = require('./lib/application.js'); 6 | const Config = require('./lib/config.js'); 7 | const Logger = require('./lib/logger.js'); 8 | const Database = require('./lib/database.js'); 9 | const Server = require('./lib/server.js'); 10 | const Channel = require('./lib/channel.js'); 11 | const initAuth = require('./lib/auth.js'); 12 | 13 | const loadCert = async (certPath) => { 14 | const key = await fsp.readFile(path.join(certPath, 'key.pem')); 15 | const cert = await fsp.readFile(path.join(certPath, 'cert.pem')); 16 | return { key, cert }; 17 | }; 18 | 19 | const main = async () => { 20 | const configPath = path.join(application.path, 'config'); 21 | const config = await new Config(configPath); 22 | const logPath = path.join(application.root, 'log'); 23 | const logger = await new Logger(logPath); 24 | Object.assign(application, { config, logger }); 25 | 26 | const logError = (err) => { 27 | logger.error(err ? err.stack : 'No exception stack available'); 28 | }; 29 | 30 | process.on('uncaughtException', logError); 31 | process.on('warning', logError); 32 | process.on('unhandledRejection', logError); 33 | 34 | const certPath = path.join(application.path, 'cert'); 35 | const cert = await loadCert(certPath).catch(() => { 36 | logError(new Error('Can not load TLS certificates')); 37 | }); 38 | 39 | application.db = new Database(config.database); 40 | application.auth = initAuth(); 41 | application.sandboxInject('auth', application.auth); 42 | 43 | const { ports } = config.server; 44 | const options = { cert, application, Channel }; 45 | for (const port of ports) { 46 | application.server = new Server(config.server, options); 47 | logger.system(`Listen port ${port}`); 48 | } 49 | 50 | await application.init(); 51 | logger.system('Application started'); 52 | 53 | const stop = () => { 54 | process.exit(0); 55 | }; 56 | 57 | process.on('SIGINT', stop); 58 | process.on('SIGTERM', stop); 59 | }; 60 | 61 | main(); 62 | -------------------------------------------------------------------------------- /application/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Metarhia Console 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node.js Starter Kit 2 | 3 | ## Concept 4 | 5 | You can begin development from this Starter Kit, but it is not for production 6 | usage. The purpose of this Starter Kit is to show simplicity, basic concepts, 7 | give structure and architecture examples. All parts of this implementation are 8 | optimized for readability and understanding, but not for performance and 9 | scalability. 10 | 11 | So it is good for development and education. However, for production deployment, 12 | you may need the [Metarhia Example App](https://github.com/metarhia/Example) an 13 | open-source application server on the top of [Node.js](https://nodejs.org/en/). 14 | 15 | ## Feature list 16 | 17 | - Pure node.js and framework-agnostic approach 18 | - Minimum code size and dependencies 19 | - Layered architecture: core, domain, API, client 20 | - Protocol-agnostic API with auto-routing, HTTP(S), WS(S) 21 | - Graceful shutdown 22 | - Code sandboxing for security, dependency injection and context isolation 23 | - Serve multiple ports 24 | - Serve static files with memory cache 25 | - Application configuration 26 | - Simple logger 27 | - Database access layer (Postgresql) 28 | - Persistent sessions 29 | - Unit-tests and API tests example 30 | - Request queue with timeout and size 31 | - Execution timeout and error handling 32 | 33 | ## Requirements 34 | 35 | - Node.js v16 or later 36 | - Linux (tested on Fedora, Ubuntu, and CentOS) 37 | - Postgresql 9.5 or later (v12 preferred) 38 | - OpenSSL v1.1.1 or later 39 | - [certbot](https://github.com/certbot/certbot) (recommended but optional) 40 | 41 | ## Usage 42 | 43 | 1. Fork and clone this repository (optionally subscribe to repo changes) 44 | 2. Remove unneeded dependencies if your project doesn't require them 45 | 3. Run `npm ci --production` to install dependencies and generate certificate 46 | 4. Add your license to `LICENSE` file but don't remove starter kit license 47 | 5. Start your project by modifying this starter kit 48 | 6. Run project with `node server.js` and stop with Ctrl+C 49 | 50 | ## Help 51 | 52 | Ask questions at https://t.me/nodeua and post issues on 53 | [github](https://github.com/HowProgrammingWorks/NodejsStarterKit/issues). 54 | 55 | ## License 56 | 57 | Copyright (c) 2020-2023 How.Programming.Works contributors. 58 | This starter kit is [MIT licensed](./LICENSE). 59 | -------------------------------------------------------------------------------- /lib/security.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { crypto } = require('./dependencies.js').node; 4 | 5 | const serializeHash = (hash, salt, params) => { 6 | const paramString = Object.entries(params) 7 | .map(([key, value]) => `${key}=${value}`) 8 | .join(','); 9 | const saltString = salt.toString('base64').split('=')[0]; 10 | const hashString = hash.toString('base64').split('=')[0]; 11 | return `$scrypt$${paramString}$${saltString}$${hashString}`; 12 | }; 13 | 14 | const deserializeHash = (phcString) => { 15 | const parsed = phcString.split('$'); 16 | parsed.shift(); 17 | if (parsed[0] !== 'scrypt') { 18 | throw new Error('Node.js crypto module only supports scrypt'); 19 | } 20 | const params = Object.fromEntries( 21 | parsed[1].split(',').map((p) => { 22 | const kv = p.split('='); 23 | kv[1] = Number(kv[1]); 24 | return kv; 25 | }), 26 | ); 27 | const salt = Buffer.from(parsed[2], 'base64'); 28 | const hash = Buffer.from(parsed[3], 'base64'); 29 | return { params, salt, hash }; 30 | }; 31 | 32 | const SALT_LEN = 32; 33 | const KEY_LEN = 64; 34 | 35 | // Only change these if you know what you're doing 36 | const SCRYPT_PARAMS = { 37 | N: 32768, 38 | r: 8, 39 | p: 1, 40 | maxmem: 64 * 1024 * 1024, 41 | }; 42 | 43 | const hashPassword = (password) => 44 | new Promise((resolve, reject) => { 45 | crypto.randomBytes(SALT_LEN, (err, salt) => { 46 | if (err) { 47 | reject(err); 48 | return; 49 | } 50 | crypto.scrypt(password, salt, KEY_LEN, SCRYPT_PARAMS, (err, hash) => { 51 | if (err) { 52 | reject(err); 53 | return; 54 | } 55 | resolve(serializeHash(hash, salt, SCRYPT_PARAMS)); 56 | }); 57 | }); 58 | }); 59 | 60 | let defaultHash; 61 | hashPassword('').then((hash) => { 62 | defaultHash = hash; 63 | }); 64 | 65 | const validatePassword = (password, hash = defaultHash) => 66 | new Promise((resolve, reject) => { 67 | const parsedHash = deserializeHash(hash); 68 | const len = parsedHash.hash.length; 69 | crypto.scrypt( 70 | password, 71 | parsedHash.salt, 72 | len, 73 | parsedHash.params, 74 | (err, hashedPassword) => { 75 | if (err) { 76 | reject(err); 77 | return; 78 | } 79 | resolve(crypto.timingSafeEqual(hashedPassword, parsedHash.hash)); 80 | }, 81 | ); 82 | }); 83 | 84 | module.exports = { hashPassword, validatePassword }; 85 | -------------------------------------------------------------------------------- /lib/database.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { npm } = require('./dependencies.js'); 4 | const { pg } = npm; 5 | const application = require('./application.js'); 6 | 7 | const OPERATORS = ['>=', '<=', '<>', '>', '<']; 8 | 9 | const where = (conditions, firstArgIndex = 1) => { 10 | const clause = []; 11 | const args = []; 12 | let i = firstArgIndex; 13 | const keys = Object.keys(conditions); 14 | for (const key of keys) { 15 | let operator = '='; 16 | let value = conditions[key]; 17 | if (typeof value === 'string') { 18 | for (const op of OPERATORS) { 19 | const len = op.length; 20 | if (value.startsWith(op)) { 21 | operator = op; 22 | value = value.substring(len); 23 | } 24 | } 25 | if (value.includes('*') || value.includes('?')) { 26 | operator = 'LIKE'; 27 | value = value.replace('*', '%').replace('?', '_'); 28 | } 29 | } 30 | clause.push(`${key} ${operator} $${i++}`); 31 | args.push(value); 32 | } 33 | return { clause: clause.join(' AND '), args }; 34 | }; 35 | 36 | const updates = (delta, firstArgIndex = 1) => { 37 | const clause = []; 38 | const args = []; 39 | let i = firstArgIndex; 40 | const keys = Object.keys(delta); 41 | for (const key of keys) { 42 | const value = delta[key].toString(); 43 | clause.push(`${key} = $${i++}`); 44 | args.push(value); 45 | } 46 | return { clause: clause.join(', '), args }; 47 | }; 48 | 49 | class Database { 50 | constructor(config) { 51 | this.pool = new pg.Pool(config); 52 | } 53 | 54 | query(sql, values) { 55 | const data = values ? values.join(',') : ''; 56 | application.logger.log(`${sql}\t[${data}]`); 57 | return this.pool.query(sql, values); 58 | } 59 | 60 | insert(table, record) { 61 | const keys = Object.keys(record); 62 | const nums = new Array(keys.length); 63 | const data = new Array(keys.length); 64 | let i = 0; 65 | for (const key of keys) { 66 | data[i] = record[key]; 67 | nums[i] = `$${++i}`; 68 | } 69 | const fields = keys.join(', '); 70 | const params = nums.join(', '); 71 | const sql = `INSERT INTO ${table} (${fields}) VALUES (${params})`; 72 | return this.query(sql, data); 73 | } 74 | 75 | async select(table, fields = ['*'], conditions = null) { 76 | const keys = fields.join(', '); 77 | const sql = `SELECT ${keys} FROM ${table}`; 78 | let whereClause = ''; 79 | let args = []; 80 | if (conditions) { 81 | const whereData = where(conditions); 82 | whereClause = ' WHERE ' + whereData.clause; 83 | args = whereData.args; 84 | } 85 | const res = await this.query(sql + whereClause, args); 86 | return res.rows; 87 | } 88 | 89 | delete(table, conditions = null) { 90 | const { clause, args } = where(conditions); 91 | const sql = `DELETE FROM ${table} WHERE ${clause}`; 92 | return this.query(sql, args); 93 | } 94 | 95 | update(table, delta = null, conditions = null) { 96 | const upd = updates(delta); 97 | const cond = where(conditions, upd.args.length + 1); 98 | const sql = `UPDATE ${table} SET ${upd.clause} WHERE ${cond.clause}`; 99 | const args = [...upd.args, ...cond.args]; 100 | return this.query(sql, args); 101 | } 102 | 103 | close() { 104 | this.pool.end(); 105 | } 106 | } 107 | 108 | module.exports = Database; 109 | -------------------------------------------------------------------------------- /application/static/console.css: -------------------------------------------------------------------------------- 1 | * { 2 | padding: 0; 3 | margin: 0; 4 | outline: none; 5 | cursor: default; 6 | -moz-user-select: inherit; 7 | } 8 | a, a * { 9 | cursor: pointer; 10 | } 11 | html, body { 12 | padding: 0; 13 | margin: 0; 14 | font-family: 'Share Tech Mono', monospace; 15 | font-size: 11pt; 16 | overflow: hidden; 17 | height: 100%; 18 | background: #000; 19 | color: #FFF; 20 | } 21 | body { 22 | height: 100%; 23 | } 24 | input, textarea { 25 | -webkit-border-radius: 0; 26 | -webkit-touch-callout: text; 27 | -webkit-user-select: text; 28 | -khtml-user-select: text; 29 | -moz-user-select: text; 30 | -ms-user-select: text; 31 | user-select: text; 32 | } 33 | ul,ol { 34 | padding: 0 0 0 30px; 35 | } 36 | #screenConsole { 37 | height: 100%; 38 | } 39 | #screenConsole > div { 40 | overflow: hidden; 41 | } 42 | #panelColors { 43 | height: 100%; 44 | width: 8px; 45 | float: left; 46 | background: #FF0000; 47 | } 48 | .colorA { background: #AA0077; height: 40px; } 49 | .colorB { background: #552277; } 50 | .colorC { background: #2222BB; } 51 | .colorD { background: #0033DD; } 52 | .colorE { background: #006699; } 53 | .colorF { background: #009933; } 54 | .colorG { background: #7FD900; } 55 | .colorH { background: #F2F200; } 56 | .colorI { background: #FFBF00; } 57 | .colorJ { background: #FF8C00; } 58 | .colorK { background: #FF3300; } 59 | .colorL { background: #FF0000; height: 40px; } 60 | #panelLogo { 61 | margin: 4px; 62 | position: absolute; 63 | top: 0; 64 | left: 8px; 65 | right: 8px; 66 | background: #000; 67 | } 68 | #panelConsole { 69 | position: absolute; 70 | left: 8px; 71 | right: 8px; 72 | top: 8px; 73 | bottom: 0; 74 | } 75 | #panelScroll { 76 | height: 100%; 77 | width: 8px; 78 | float: right; 79 | background: #262626; 80 | } 81 | #controlScroll { 82 | background: #009933; 83 | position: absolute; 84 | width: 8px; 85 | height: 25px; 86 | bottom: 0px; 87 | } 88 | #panelColors > div { 89 | height: 25pt; 90 | } 91 | #controlShadow { 92 | position: absolute; 93 | top: -10px; 94 | left: 0; 95 | right: 0; 96 | height: 10px; 97 | box-shadow: 0px 0px 20px 6px rgba(0,0,0,1); 98 | z-index: 1; 99 | } 100 | #controlBrowse { 101 | padding: 4px; 102 | color: #009933; 103 | overflow-x: hidden; 104 | overflow-y: scroll; 105 | position: absolute; 106 | left: 0; 107 | right: 0; 108 | top: 0; 109 | bottom: 0; 110 | } 111 | #controlBrowse td { 112 | padding: 2px 4px; 113 | } 114 | #controlBrowse th { 115 | padding: 2px 4px; 116 | font-weight: bold; 117 | color: #FFBF00; 118 | } 119 | #controlBrowse tr:nth-child(even) { 120 | background: #101010; 121 | } 122 | #controlBrowse tr:nth-child(odd) { 123 | background: #262626; 124 | } 125 | #controlInput { 126 | color: #009933; 127 | } 128 | #controlInput span { 129 | animation: blinker 1s ease-out infinite; 130 | margin-left: 2px; 131 | } 132 | @keyframes blinker { 133 | 50% { opacity: 0.0; } 134 | } 135 | #controlKeyboard { 136 | background: #000; 137 | position: absolute; 138 | bottom: 0; 139 | right: 0; 140 | left: 0; 141 | } 142 | #controlKeyboard .key { 143 | font-size: 1.4em; 144 | background: #262626; 145 | display: inline-block; 146 | text-align: center; 147 | width: 10%; 148 | height: 25px; 149 | } 150 | .caps { 151 | text-transform: uppercase; 152 | } 153 | ::-webkit-scrollbar { 154 | display: none; 155 | } 156 | #controlBrowseSpacer { 157 | height: 100%; 158 | } 159 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { node, npm } = require('./dependencies.js'); 4 | const { http, https } = node; 5 | const { common, ws } = npm; 6 | const { Channel } = require('./channel.js'); 7 | const { application } = require('./application.js'); 8 | 9 | const SHUTDOWN_TIMEOUT = 5000; 10 | const SHORT_TIMEOUT = 500; 11 | const LONG_RESPONSE = 30000; 12 | 13 | const receiveBody = async (req) => { 14 | const buffers = []; 15 | for await (const chunk of req) { 16 | buffers.push(chunk); 17 | } 18 | return Buffer.concat(buffers).toString(); 19 | }; 20 | 21 | class Server { 22 | constructor(config) { 23 | this.config = config; 24 | this.channels = new Map(); 25 | const { port, host, protocol, cert } = config; 26 | this.port = port; 27 | this.host = host; 28 | this.protocol = protocol; 29 | const transport = protocol === 'http' ? http : https; 30 | const listener = this.listener.bind(this); 31 | this.server = transport.createServer(cert, listener); 32 | this.ws = new ws.Server({ server: this.server }); 33 | this.ws.on('connection', async (connection, req) => { 34 | const channel = await new Channel(req, null, connection, application); 35 | connection.on('message', (data) => { 36 | channel.message(data); 37 | }); 38 | }); 39 | this.server.listen(this.port, host); 40 | } 41 | 42 | async listener(req, res) { 43 | let finished = false; 44 | const { url, connection } = req; 45 | const channel = await new Channel(req, res, null, application); 46 | this.channels.set(connection, channel); 47 | 48 | const timer = setTimeout(() => { 49 | if (finished) return; 50 | finished = true; 51 | this.channels.delete(connection); 52 | channel.error(504); 53 | }, LONG_RESPONSE); 54 | 55 | res.on('close', () => { 56 | if (finished) return; 57 | finished = true; 58 | clearTimeout(timer); 59 | this.channels.delete(connection); 60 | }); 61 | 62 | if (url.startsWith('/api')) this.request(channel); 63 | else channel.static(); 64 | } 65 | 66 | request(channel) { 67 | const { req } = channel; 68 | if (req.method === 'OPTIONS') { 69 | channel.options(); 70 | return; 71 | } 72 | if (req.method !== 'POST') { 73 | channel.error(403); 74 | return; 75 | } 76 | const body = receiveBody(req); 77 | if (req.url === '/api') { 78 | body.then((data) => { 79 | channel.message(data); 80 | }); 81 | } else { 82 | body.then((data) => { 83 | const { pathname, searchParams } = new URL('http://' + req.url); 84 | const [, interfaceName, methodName] = pathname.split('/'); 85 | const args = data ? JSON.parse(data) : Object.fromEntries(searchParams); 86 | channel.rpc(-1, interfaceName, methodName, args); 87 | }); 88 | } 89 | body.catch((err) => { 90 | channel.error(500, err); 91 | }); 92 | } 93 | 94 | closeChannels() { 95 | const { channels } = this; 96 | for (const [connection, channel] of channels.entries()) { 97 | channels.delete(connection); 98 | channel.error(503); 99 | connection.destroy(); 100 | } 101 | } 102 | 103 | async close() { 104 | this.server.close((err) => { 105 | if (err) application.logger.error(err.stack); 106 | }); 107 | if (this.channels.size === 0) { 108 | await common.timeout(SHORT_TIMEOUT); 109 | return; 110 | } 111 | await common.timeout(SHUTDOWN_TIMEOUT); 112 | this.closeChannels(); 113 | } 114 | } 115 | 116 | module.exports = Server; 117 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 2020 10 | }, 11 | "globals": { 12 | "BigInt": true 13 | }, 14 | "rules": { 15 | "indent": ["error", 2], 16 | "linebreak-style": ["error", "unix"], 17 | "quotes": ["error", "single"], 18 | "semi": ["error", "always"], 19 | "no-loop-func": ["error"], 20 | "block-spacing": ["error", "always"], 21 | "camelcase": ["error"], 22 | "eqeqeq": ["error", "always"], 23 | "strict": ["error", "global"], 24 | "brace-style": [ 25 | "error", 26 | "1tbs", 27 | { 28 | "allowSingleLine": true 29 | } 30 | ], 31 | "comma-style": ["error", "last"], 32 | "comma-spacing": [ 33 | "error", 34 | { 35 | "before": false, 36 | "after": true 37 | } 38 | ], 39 | "eol-last": ["error"], 40 | "func-call-spacing": ["error", "never"], 41 | "key-spacing": [ 42 | "error", 43 | { 44 | "beforeColon": false, 45 | "afterColon": true, 46 | "mode": "minimum" 47 | } 48 | ], 49 | "keyword-spacing": [ 50 | "error", 51 | { 52 | "before": true, 53 | "after": true, 54 | "overrides": { 55 | "function": { 56 | "after": false 57 | } 58 | } 59 | } 60 | ], 61 | "max-len": [ 62 | "error", 63 | { 64 | "code": 80, 65 | "ignoreUrls": true 66 | } 67 | ], 68 | "max-nested-callbacks": [ 69 | "error", 70 | { 71 | "max": 7 72 | } 73 | ], 74 | "new-cap": [ 75 | "error", 76 | { 77 | "newIsCap": true, 78 | "capIsNew": false, 79 | "properties": true 80 | } 81 | ], 82 | "new-parens": ["error"], 83 | "no-lonely-if": ["error"], 84 | "no-trailing-spaces": ["error"], 85 | "no-unneeded-ternary": ["error"], 86 | "no-whitespace-before-property": ["error"], 87 | "object-curly-spacing": ["error", "always"], 88 | "operator-assignment": ["error", "always"], 89 | "operator-linebreak": ["error", "after"], 90 | "semi-spacing": [ 91 | "error", 92 | { 93 | "before": false, 94 | "after": true 95 | } 96 | ], 97 | "space-before-blocks": ["error", "always"], 98 | "space-before-function-paren": [ 99 | "error", 100 | { 101 | "anonymous": "never", 102 | "named": "never", 103 | "asyncArrow": "always" 104 | } 105 | ], 106 | "space-in-parens": ["error", "never"], 107 | "space-infix-ops": ["error"], 108 | "space-unary-ops": [ 109 | "error", 110 | { 111 | "words": true, 112 | "nonwords": false, 113 | "overrides": { 114 | "typeof": false 115 | } 116 | } 117 | ], 118 | "no-unreachable": ["error"], 119 | "no-global-assign": ["error"], 120 | "no-self-compare": ["error"], 121 | "no-unmodified-loop-condition": ["error"], 122 | "no-constant-condition": [ 123 | "error", 124 | { 125 | "checkLoops": false 126 | } 127 | ], 128 | "no-console": ["off"], 129 | "no-useless-concat": ["error"], 130 | "no-useless-escape": ["error"], 131 | "no-shadow-restricted-names": ["error"], 132 | "no-use-before-define": [ 133 | "error", 134 | { 135 | "functions": false 136 | } 137 | ], 138 | "arrow-parens": ["error", "always"], 139 | "arrow-body-style": ["error", "as-needed"], 140 | "arrow-spacing": ["error"], 141 | "no-confusing-arrow": [ 142 | "error", 143 | { 144 | "allowParens": true 145 | } 146 | ], 147 | "no-useless-computed-key": ["error"], 148 | "no-useless-rename": ["error"], 149 | "no-var": ["error"], 150 | "object-shorthand": ["error", "always"], 151 | "prefer-arrow-callback": ["error"], 152 | "prefer-const": ["error"], 153 | "prefer-numeric-literals": ["error"], 154 | "prefer-rest-params": ["error"], 155 | "prefer-spread": ["error"], 156 | "rest-spread-spacing": ["error", "never"], 157 | "template-curly-spacing": ["error", "never"] 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { node, npm } = require('./dependencies.js'); 4 | const { crypto } = node; 5 | const { common } = npm; 6 | const application = require('./application.js'); 7 | 8 | const BYTE = 256; 9 | const TOKEN = 'token'; 10 | const TOKEN_LENGTH = 32; 11 | const ALPHA_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 12 | const ALPHA_LOWER = 'abcdefghijklmnopqrstuvwxyz'; 13 | const ALPHA = ALPHA_UPPER + ALPHA_LOWER; 14 | const DIGIT = '0123456789'; 15 | const ALPHA_DIGIT = ALPHA + DIGIT; 16 | const EPOCH = 'Thu, 01 Jan 1970 00:00:00 GMT'; 17 | const FUTURE = 'Fri, 01 Jan 2100 00:00:00 GMT'; 18 | const LOCATION = 'Path=/; Domain'; 19 | const COOKIE_DELETE = `${TOKEN}=deleted; Expires=${EPOCH}; ${LOCATION}=`; 20 | const COOKIE_HOST = `Expires=${FUTURE}; ${LOCATION}`; 21 | 22 | const sessions = new Map(); 23 | const cache = new WeakMap(); 24 | 25 | const generateToken = () => { 26 | const base = ALPHA_DIGIT.length; 27 | const bytes = crypto.randomBytes(base); 28 | let key = ''; 29 | for (let i = 0; i < TOKEN_LENGTH; i++) { 30 | const index = ((bytes[i] * base) / BYTE) | 0; 31 | key += ALPHA_DIGIT[index]; 32 | } 33 | return key; 34 | }; 35 | 36 | const parseCookies = (cookie) => { 37 | const values = {}; 38 | const items = cookie.split(';'); 39 | for (const item of items) { 40 | const parts = item.split('='); 41 | const key = parts[0].trim(); 42 | const val = parts[1] || ''; 43 | values[key] = val.trim(); 44 | } 45 | return values; 46 | }; 47 | 48 | module.exports = () => { 49 | const { db } = application; 50 | 51 | const save = (token, context) => { 52 | const data = JSON.stringify(context); 53 | db.update('Session', { data }, { token }); 54 | }; 55 | 56 | class Session { 57 | constructor(token, contextData = { token }) { 58 | const contextHandler = { 59 | get: (data, key) => { 60 | if (key === 'token') return this.token; 61 | return Reflect.get(data, key); 62 | }, 63 | set: (data, key, value) => { 64 | const res = Reflect.set(data, key, value); 65 | save(token, this.data); 66 | return res; 67 | }, 68 | }; 69 | this.token = token; 70 | this.data = contextData; 71 | this.context = new Proxy(contextData, contextHandler); 72 | } 73 | } 74 | 75 | const start = (client, userId) => { 76 | const token = generateToken(); 77 | const host = common.parseHost(client.req.headers.host); 78 | const ip = client.req.connection.remoteAddress; 79 | const cookie = `${TOKEN}=${token}; ${COOKIE_HOST}=${host}; HttpOnly`; 80 | const session = new Session(token); 81 | sessions.set(token, session); 82 | cache.set(client.req, session); 83 | const data = JSON.stringify(session.data); 84 | db.insert('Session', { userId, token, ip, data }); 85 | if (client.res) client.res.setHeader('Set-Cookie', cookie); 86 | return session; 87 | }; 88 | 89 | const restore = async (client) => { 90 | const cachedSession = cache.get(client.req); 91 | if (cachedSession) return cachedSession; 92 | const { cookie } = client.req.headers; 93 | if (!cookie) return null; 94 | const cookies = parseCookies(cookie); 95 | const { token } = cookies; 96 | if (!token) return null; 97 | let session = sessions.get(token); 98 | if (!session) { 99 | const [record] = await db.select('Session', ['Data'], { token }); 100 | if (record && record.data) { 101 | const data = JSON.parse(record.data); 102 | session = new Session(token, data); 103 | sessions.set(token, session); 104 | } 105 | } 106 | if (!session) return null; 107 | cache.set(client.req, session); 108 | return session; 109 | }; 110 | 111 | const remove = (client, token) => { 112 | const host = common.parseHost(client.req.headers.host); 113 | client.res.setHeader('Set-Cookie', COOKIE_DELETE + host); 114 | sessions.delete(token); 115 | db.delete('Session', { token }); 116 | }; 117 | 118 | const registerUser = (login, password, fullName) => { 119 | db.insert('SystemUser', { login, password, fullName }); 120 | }; 121 | 122 | const getUser = (login) => 123 | db 124 | .select('SystemUser', ['Id', 'Password'], { login }) 125 | .then(([user]) => user); 126 | 127 | return { start, restore, remove, save, registerUser, getUser }; 128 | }; 129 | -------------------------------------------------------------------------------- /application/static/metacom.js: -------------------------------------------------------------------------------- 1 | class MetacomError extends Error { 2 | constructor(message, code) { 3 | super(message); 4 | this.code = code; 5 | } 6 | } 7 | 8 | class MetacomInterface { 9 | constructor() { 10 | this._events = new Map(); 11 | } 12 | 13 | on(name, fn) { 14 | const event = this._events.get(name); 15 | if (event) event.add(fn); 16 | else this._events.set(name, new Set([fn])); 17 | } 18 | 19 | emit(name, ...args) { 20 | const event = this._events.get(name); 21 | if (!event) return; 22 | for (const fn of event.values()) fn(...args); 23 | } 24 | } 25 | 26 | export class Metacom { 27 | constructor(url) { 28 | this.url = url; 29 | this.socket = new WebSocket(url); 30 | this.api = {}; 31 | this.callId = 0; 32 | this.calls = new Map(); 33 | this.socket.addEventListener('message', ({ data }) => { 34 | this.message(data); 35 | }); 36 | } 37 | 38 | message(data) { 39 | let packet; 40 | try { 41 | packet = JSON.parse(data); 42 | } catch (err) { 43 | console.error(err); 44 | return; 45 | } 46 | const [callType, target] = Object.keys(packet); 47 | const callId = packet[callType]; 48 | const args = packet[target]; 49 | if (callId && args) { 50 | if (callType === 'callback') { 51 | const promised = this.calls.get(callId); 52 | if (!promised) return; 53 | const [resolve, reject] = promised; 54 | if (packet.error) { 55 | const { message, code } = packet.error; 56 | const error = new MetacomError(message, code); 57 | reject(error); 58 | return; 59 | } 60 | resolve(args); 61 | return; 62 | } 63 | if (callType === 'event') { 64 | const [interfaceName, eventName] = target.split('/'); 65 | const metacomInterface = this.api[interfaceName]; 66 | metacomInterface.emit(eventName, args); 67 | } 68 | } 69 | } 70 | 71 | ready() { 72 | return new Promise((resolve) => { 73 | if (this.socket.readyState === WebSocket.OPEN) resolve(); 74 | else this.socket.addEventListener('open', resolve); 75 | }); 76 | } 77 | 78 | async load(...interfaces) { 79 | const introspect = this.httpCall('system')('introspect'); 80 | const introspection = await introspect(interfaces); 81 | const available = Object.keys(introspection); 82 | for (const interfaceName of interfaces) { 83 | if (!available.includes(interfaceName)) continue; 84 | const methods = new MetacomInterface(); 85 | const iface = introspection[interfaceName]; 86 | const request = this.socketCall(interfaceName); 87 | const methodNames = Object.keys(iface); 88 | for (const methodName of methodNames) { 89 | methods[methodName] = request(methodName); 90 | } 91 | this.api[interfaceName] = methods; 92 | } 93 | } 94 | 95 | httpCall(iname, ver) { 96 | return (methodName) => 97 | (args = {}) => { 98 | const callId = ++this.callId; 99 | const interfaceName = ver ? `${iname}.${ver}` : iname; 100 | const target = interfaceName + '/' + methodName; 101 | const packet = { call: callId, [target]: args }; 102 | const dest = new URL(this.url); 103 | const protocol = dest.protocol === 'ws:' ? 'http' : 'https'; 104 | const url = `${protocol}://${dest.host}/api`; 105 | return fetch(url, { 106 | method: 'POST', 107 | headers: { 'Content-Type': 'application/json' }, 108 | body: JSON.stringify(packet), 109 | }).then((res) => { 110 | const { status } = res; 111 | if (status === 200) return res.json().then(({ result }) => result); 112 | throw new Error(`Status Code: ${status}`); 113 | }); 114 | }; 115 | } 116 | 117 | socketCall(iname, ver) { 118 | return (methodName) => 119 | async (args = {}) => { 120 | const callId = ++this.callId; 121 | const interfaceName = ver ? `${iname}.${ver}` : iname; 122 | const target = interfaceName + '/' + methodName; 123 | await this.ready(); 124 | return new Promise((resolve, reject) => { 125 | this.calls.set(callId, [resolve, reject]); 126 | const packet = { call: callId, [target]: args }; 127 | this.socket.send(JSON.stringify(packet)); 128 | }); 129 | }; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/channel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { http, path } = require('./dependencies.js').node; 4 | 5 | const MIME_TYPES = { 6 | html: 'text/html; charset=UTF-8', 7 | json: 'application/json; charset=UTF-8', 8 | js: 'application/javascript; charset=UTF-8', 9 | css: 'text/css', 10 | png: 'image/png', 11 | ico: 'image/x-icon', 12 | svg: 'image/svg+xml', 13 | }; 14 | 15 | const HEADERS = { 16 | 'X-XSS-Protection': '1; mode=block', 17 | 'X-Content-Type-Options': 'nosniff', 18 | 'Strict-Transport-Security': 'max-age=31536000; includeSubdomains; preload', 19 | 'Access-Control-Allow-Origin': '*', 20 | 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 21 | 'Access-Control-Allow-Headers': 'Content-Type', 22 | 'Content-Security-Policy': [ 23 | "default-src 'self' ws:", 24 | "style-src 'self' https://fonts.googleapis.com", 25 | "font-src 'self' https://fonts.gstatic.com", 26 | ].join('; '), 27 | }; 28 | 29 | class Client { 30 | constructor(connection) { 31 | this.callId = 0; 32 | this.connection = connection; 33 | } 34 | 35 | emit(name, data) { 36 | const packet = { event: --this.callId, [name]: data }; 37 | this.connection.send(JSON.stringify(packet)); 38 | } 39 | } 40 | 41 | class Channel { 42 | constructor(req, res, connection, application) { 43 | this.req = req; 44 | this.res = res; 45 | this.ip = req.socket.remoteAddress; 46 | this.connection = connection; 47 | this.application = application; 48 | this.client = new Client(connection); 49 | this.session = null; 50 | return this.init(); 51 | } 52 | 53 | async init() { 54 | this.session = await this.application.auth.restore(this); 55 | return this; 56 | } 57 | 58 | static() { 59 | const { 60 | req: { url, method }, 61 | res, 62 | ip, 63 | application, 64 | } = this; 65 | const filePath = url === '/' ? '/index.html' : url; 66 | const fileExt = path.extname(filePath).substring(1); 67 | const mimeType = MIME_TYPES[fileExt] || MIME_TYPES.html; 68 | res.writeHead(200, { ...HEADERS, 'Content-Type': mimeType }); 69 | if (res.writableEnded) return; 70 | const data = application.getStaticFile(filePath); 71 | if (data) { 72 | res.end(data); 73 | application.logger.access(`${ip}\t${method}\t${url}`); 74 | return; 75 | } 76 | this.error(404); 77 | } 78 | 79 | redirect(location) { 80 | const { res } = this; 81 | if (res.headersSent) return; 82 | res.writeHead(302, { Location: location, ...HEADERS }); 83 | res.end(); 84 | } 85 | 86 | options() { 87 | const { res } = this; 88 | if (res.headersSent) return; 89 | res.writeHead(200, HEADERS); 90 | res.end(); 91 | } 92 | 93 | error(code, err, callId = err) { 94 | const { 95 | req: { url, method }, 96 | res, 97 | connection, 98 | ip, 99 | application, 100 | } = this; 101 | const status = http.STATUS_CODES[code]; 102 | if (typeof err === 'number') err = undefined; 103 | const reason = err ? err.stack : status; 104 | application.logger.error(`${ip}\t${method}\t${url}\t${code}\t${reason}`); 105 | const { Error } = this.application; 106 | const message = err instanceof Error ? err.message : status; 107 | const error = { message, code }; 108 | if (connection) { 109 | connection.send(JSON.stringify({ callback: callId, error })); 110 | return; 111 | } 112 | if (res.writableEnded) return; 113 | res.writeHead(code, { 'Content-Type': MIME_TYPES.json, ...HEADERS }); 114 | res.end(JSON.stringify({ error })); 115 | } 116 | 117 | message(data) { 118 | let packet; 119 | try { 120 | packet = JSON.parse(data); 121 | } catch (err) { 122 | this.error(500, new Error('JSON parsing error')); 123 | return; 124 | } 125 | const [callType, target] = Object.keys(packet); 126 | const callId = packet[callType]; 127 | const args = packet[target]; 128 | if (callId && args) { 129 | const [interfaceName, methodName] = target.split('/'); 130 | this.rpc(callId, interfaceName, methodName, args); 131 | return; 132 | } 133 | this.error(500, new Error('Packet structure error')); 134 | } 135 | 136 | async rpc(callId, interfaceName, methodName, args) { 137 | const { res, connection, ip, application, session, client } = this; 138 | const [iname, ver = '*'] = interfaceName.split('.'); 139 | try { 140 | const context = session ? session.context : { client }; 141 | const proc = application.getMethod(iname, ver, methodName, context); 142 | if (!proc) { 143 | this.error(404, callId); 144 | return; 145 | } 146 | if (!this.session && proc.access !== 'public') { 147 | this.error(403, callId); 148 | return; 149 | } 150 | const result = await proc.method(args); 151 | if (result instanceof Error) { 152 | this.error(result.code, result, callId); 153 | return; 154 | } 155 | const userId = result ? result.userId : undefined; 156 | if (!this.session && userId && proc.access === 'public') { 157 | this.session = application.auth.start(this, userId); 158 | result.token = this.session.token; 159 | } 160 | const data = JSON.stringify({ callback: callId, result }); 161 | if (connection) { 162 | connection.send(data); 163 | } else { 164 | res.writeHead(200, { 'Content-Type': MIME_TYPES.json, ...HEADERS }); 165 | res.end(data); 166 | } 167 | const token = this.session ? this.session.token : 'anonymous'; 168 | const record = `${ip}\t${token}\t${interfaceName}/${methodName}`; 169 | application.logger.access(record); 170 | } catch (err) { 171 | this.error(500, err, callId); 172 | } 173 | } 174 | } 175 | 176 | module.exports = Channel; 177 | -------------------------------------------------------------------------------- /lib/application.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { node, npm } = require('./dependencies.js'); 4 | const { path, events, vm, fs, fsp } = node; 5 | const { common } = npm; 6 | const security = require('./security.js'); 7 | 8 | const SCRIPT_OPTIONS = { timeout: 5000 }; 9 | const EMPTY_CONTEXT = Object.freeze({}); 10 | const MODULE = 2; 11 | 12 | class Application extends events.EventEmitter { 13 | constructor() { 14 | super(); 15 | this.initialization = true; 16 | this.finalization = false; 17 | this.namespaces = ['db']; 18 | this.api = {}; 19 | this.static = new Map(); 20 | this.root = process.cwd(); 21 | this.path = path.join(this.root, 'application'); 22 | this.apiPath = path.join(this.path, 'api'); 23 | this.libPath = path.join(this.path, 'lib'); 24 | this.domainPath = path.join(this.path, 'domain'); 25 | this.staticPath = path.join(this.path, 'static'); 26 | this.starts = []; 27 | } 28 | 29 | async init() { 30 | this.createSandbox(); 31 | await Promise.allSettled([ 32 | this.loadPlace('static', this.staticPath), 33 | this.loadPlace('api', this.apiPath), 34 | (async () => { 35 | await this.loadPlace('lib', this.libPath); 36 | await this.loadPlace('domain', this.domainPath); 37 | })(), 38 | ]); 39 | await Promise.allSettled(this.starts.map((fn) => fn())); 40 | this.starts = null; 41 | this.initialization = true; 42 | } 43 | 44 | async shutdown() { 45 | this.finalization = true; 46 | await this.stopPlace('domain'); 47 | await this.stopPlace('lib'); 48 | if (this.server) await this.server.close(); 49 | await this.logger.close(); 50 | } 51 | 52 | async stopPlace(name) { 53 | const place = this.sandbox[name]; 54 | for (const moduleName of Object.keys(place)) { 55 | const module = place[moduleName]; 56 | if (module.stop) await this.execute(module.stop); 57 | } 58 | } 59 | 60 | createSandbox() { 61 | const { config, namespaces, server: { host, port, protocol } = {} } = this; 62 | const introspect = async (interfaces) => this.introspect(interfaces); 63 | const worker = { id: 'W' + node.worker.threadId.toString() }; 64 | const server = { host, port, protocol }; 65 | const application = { security, introspect, worker, server }; 66 | const api = {}; 67 | const lib = {}; 68 | const domain = {}; 69 | for (const name of namespaces) application[name] = this[name]; 70 | const sandbox = { 71 | Buffer, 72 | URL, 73 | URLSearchParams, 74 | Error: this.Error, 75 | console: this.logger, 76 | application, 77 | node, 78 | npm, 79 | api, 80 | lib, 81 | domain, 82 | config, 83 | setTimeout, 84 | setImmediate, 85 | setInterval, 86 | clearTimeout, 87 | clearImmediate, 88 | clearInterval, 89 | }; 90 | this.sandbox = vm.createContext(sandbox); 91 | } 92 | 93 | sandboxInject(name, module) { 94 | this[name] = Object.freeze(module); 95 | this.namespaces.push(name); 96 | } 97 | 98 | async createScript(fileName) { 99 | try { 100 | const code = await fsp.readFile(fileName, 'utf8'); 101 | if (!code) return null; 102 | const src = `'use strict';\n(context) => ${code}`; 103 | const options = { filename: fileName, lineOffset: -1 }; 104 | const script = new vm.Script(src, options); 105 | return script.runInContext(this.sandbox, SCRIPT_OPTIONS); 106 | } catch (err) { 107 | if (err.code !== 'ENOENT') { 108 | this.logger.error(err.stack); 109 | } 110 | return null; 111 | } 112 | } 113 | 114 | getMethod(iname, ver, methodName, context) { 115 | const iface = this.api[iname]; 116 | if (!iface) return null; 117 | const version = ver === '*' ? iface.default : parseInt(ver); 118 | const methods = iface[version.toString()]; 119 | if (!methods) return null; 120 | const method = methods[methodName]; 121 | if (!method) return null; 122 | const exp = method(context); 123 | return typeof exp === 'object' ? exp : { access: 'logged', method: exp }; 124 | } 125 | 126 | async loadMethod(fileName) { 127 | const rel = fileName.substring(this.apiPath.length + 1); 128 | if (!rel.includes('/')) return; 129 | const [interfaceName, methodFile] = rel.split('/'); 130 | if (!methodFile.endsWith('.js')) return; 131 | const name = path.basename(methodFile, '.js'); 132 | const [iname, ver] = interfaceName.split('.'); 133 | const version = parseInt(ver, 10); 134 | const script = await this.createScript(fileName); 135 | if (!script) return; 136 | let iface = this.api[iname]; 137 | const { api } = this.sandbox; 138 | let internalInterface = api[iname]; 139 | if (!iface) { 140 | this.api[iname] = iface = { default: version }; 141 | api[iname] = internalInterface = {}; 142 | } 143 | let methods = iface[ver]; 144 | if (!methods) iface[ver] = methods = {}; 145 | methods[name] = script; 146 | internalInterface[name] = script(EMPTY_CONTEXT); 147 | if (version > iface.default) iface.default = version; 148 | } 149 | 150 | async loadModule(fileName) { 151 | const rel = fileName.substring(this.path.length + 1); 152 | if (!rel.endsWith('.js')) return; 153 | const script = await this.createScript(fileName); 154 | const name = path.basename(rel, '.js'); 155 | const namespaces = rel.split(path.sep); 156 | namespaces[namespaces.length - 1] = name; 157 | const exp = script ? script(EMPTY_CONTEXT) : null; 158 | const container = typeof exp === 'function' ? { method: exp } : exp; 159 | const iface = {}; 160 | if (container !== null) { 161 | const methods = Object.keys(container); 162 | for (const method of methods) { 163 | const fn = container[method]; 164 | if (typeof fn === 'function') { 165 | container[method] = iface[method] = fn.bind(container); 166 | } 167 | } 168 | } 169 | let level = this.sandbox; 170 | const last = namespaces.length - 1; 171 | for (let depth = 0; depth <= last; depth++) { 172 | const namespace = namespaces[depth]; 173 | let next = level[namespace]; 174 | if (next) { 175 | if (depth === MODULE && namespace === 'stop') { 176 | if (exp === null && level.stop) await this.execute(level.stop); 177 | } 178 | } else { 179 | next = depth === last ? iface : {}; 180 | level[namespace] = iface.method || iface; 181 | container.parent = level; 182 | if (depth === MODULE && namespace === 'start') { 183 | this.starts.push(iface.method); 184 | } 185 | } 186 | level = next; 187 | } 188 | } 189 | 190 | async execute(fn) { 191 | try { 192 | await fn(); 193 | } catch (err) { 194 | this.logger.error(err.stack); 195 | } 196 | } 197 | 198 | async loadFile(filePath) { 199 | const key = filePath.substring(this.staticPath.length); 200 | try { 201 | const data = await fsp.readFile(filePath); 202 | this.static.set(key, data); 203 | } catch (err) { 204 | if (err.code !== 'ENOENT') { 205 | this.logger.error(err.stack); 206 | } 207 | } 208 | } 209 | 210 | async loadPlace(place, placePath) { 211 | const files = await fsp.readdir(placePath, { withFileTypes: true }); 212 | for (const file of files) { 213 | if (file.name.startsWith('.')) continue; 214 | const filePath = path.join(placePath, file.name); 215 | if (file.isDirectory()) await this.loadPlace(place, filePath); 216 | else if (place === 'api') await this.loadMethod(filePath); 217 | else if (place === 'static') await this.loadFile(filePath); 218 | else await this.loadModule(filePath); 219 | } 220 | this.watch(place, placePath); 221 | } 222 | 223 | watch(place, placePath) { 224 | fs.watch(placePath, async (event, fileName) => { 225 | if (fileName.startsWith('.')) return; 226 | const filePath = path.join(placePath, fileName); 227 | try { 228 | const stat = await node.fsp.stat(filePath); 229 | if (stat.isDirectory()) { 230 | this.loadPlace(place, filePath); 231 | return; 232 | } 233 | } catch { 234 | return; 235 | } 236 | if (node.worker.threadId === 1) { 237 | const relPath = filePath.substring(this.path.length); 238 | this.logger.debug('Reload: ' + relPath); 239 | } 240 | if (place === 'api') this.loadMethod(filePath); 241 | else if (place === 'static') this.loadFile(filePath); 242 | else this.loadModule(filePath); 243 | }); 244 | } 245 | 246 | introspect(interfaces) { 247 | const intro = {}; 248 | for (const interfaceName of interfaces) { 249 | const [iname, ver = '*'] = interfaceName.split('.'); 250 | const iface = this.api[iname]; 251 | if (!iface) continue; 252 | const version = ver === '*' ? iface.default : parseInt(ver); 253 | const methods = iface[version.toString()]; 254 | const methodNames = Object.keys(methods); 255 | const interfaceMethods = (intro[iname] = {}); 256 | for (const methodName of methodNames) { 257 | const exp = methods[methodName](EMPTY_CONTEXT); 258 | const fn = typeof exp === 'object' ? exp.method : exp; 259 | const src = fn.toString(); 260 | const signature = common.between(src, '({', '})'); 261 | if (signature === '') { 262 | interfaceMethods[methodName] = []; 263 | continue; 264 | } 265 | const args = signature.split(',').map((s) => s.trim()); 266 | interfaceMethods[methodName] = args; 267 | } 268 | } 269 | return intro; 270 | } 271 | 272 | getStaticFile(fileName) { 273 | return this.static.get(fileName); 274 | } 275 | } 276 | 277 | const application = new Application(); 278 | 279 | application.Error = class extends Error { 280 | constructor(message, code) { 281 | super(message); 282 | this.code = code; 283 | } 284 | }; 285 | 286 | module.exports = application; 287 | -------------------------------------------------------------------------------- /application/static/console.js: -------------------------------------------------------------------------------- 1 | import { Metacom } from './metacom.js'; 2 | 3 | const protocol = location.protocol === 'http:' ? 'ws' : 'wss'; 4 | const metacom = new Metacom(`${protocol}://${location.host}`); 5 | const { api } = metacom; 6 | window.api = api; 7 | 8 | const ALPHA_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 9 | const ALPHA_LOWER = 'abcdefghijklmnopqrstuvwxyz'; 10 | const ALPHA = ALPHA_UPPER + ALPHA_LOWER; 11 | const DIGIT = '0123456789'; 12 | const CHARS = ALPHA + DIGIT; 13 | const TIME_LINE = 300; 14 | const TIME_CHAR = 20; 15 | 16 | const KEY_CODE = { 17 | BACKSPACE: 8, 18 | TAB: 9, 19 | ENTER: 13, 20 | PAUSE: 19, 21 | ESC: 27, 22 | SPACE: 32, 23 | PGUP: 33, 24 | PGDN: 34, 25 | END: 35, 26 | HOME: 36, 27 | LT: 37, 28 | UP: 38, 29 | RT: 39, 30 | DN: 40, 31 | INS: 45, 32 | DEL: 46, 33 | F1: 112, 34 | F2: 113, 35 | F3: 114, 36 | F4: 115, 37 | F5: 116, 38 | F6: 117, 39 | F7: 118, 40 | F8: 119, 41 | F9: 120, 42 | F10: 121, 43 | F11: 122, 44 | F12: 123, 45 | ACCENT: 192, 46 | }; 47 | 48 | const KEY_NAME = {}; 49 | for (const keyName in KEY_CODE) KEY_NAME[KEY_CODE[keyName]] = keyName; 50 | 51 | let controlKeyboard, panelScroll; 52 | let controlInput, controlBrowse, controlScroll; 53 | 54 | const pad = (padChar, length) => new Array(length + 1).join(padChar); 55 | 56 | const { userAgent } = navigator; 57 | 58 | const isMobile = () => 59 | userAgent.match(/Android/i) || 60 | userAgent.match(/webOS/i) || 61 | userAgent.match(/iPhone/i) || 62 | userAgent.match(/iPad/i) || 63 | userAgent.match(/iPod/i) || 64 | userAgent.match(/BlackBerry/i) || 65 | userAgent.match(/Windows Phone/i); 66 | 67 | let viewportHeight, viewableRatio; 68 | let contentHeight, scrollHeight; 69 | let thumbHeight, thumbPosition; 70 | 71 | const refreshScroll = () => { 72 | viewportHeight = controlBrowse.offsetHeight; 73 | contentHeight = controlBrowse.scrollHeight; 74 | viewableRatio = viewportHeight / contentHeight; 75 | scrollHeight = panelScroll.offsetHeight; 76 | thumbHeight = scrollHeight * viewableRatio; 77 | thumbPosition = (controlBrowse.scrollTop * thumbHeight) / viewportHeight; 78 | controlScroll.style.top = thumbPosition + 'px'; 79 | controlScroll.style.height = thumbHeight + 'px'; 80 | }; 81 | 82 | const scrollBottom = () => { 83 | refreshScroll(); 84 | controlBrowse.scrollTop = controlBrowse.scrollHeight; 85 | }; 86 | 87 | const initScroll = () => { 88 | controlBrowse.scrollTop = controlBrowse.scrollHeight; 89 | controlBrowse.addEventListener('scroll', refreshScroll); 90 | window.addEventListener('orientationchange', () => { 91 | setTimeout(scrollBottom, 0); 92 | }); 93 | }; 94 | 95 | const showKeyboard = () => { 96 | if (!isMobile()) return; 97 | controlKeyboard.style.display = 'block'; 98 | controlBrowse.style.bottom = controlKeyboard.offsetHeight + 'px'; 99 | }; 100 | 101 | const inputSetValue = (value) => { 102 | controlInput.inputValue = value; 103 | if (controlInput.inputType === 'masked') { 104 | value = pad('*', value.length); 105 | } 106 | value = value.replace(/ /g, ' '); 107 | controlInput.innerHTML = 108 | controlInput.inputPrompt + value + ''; 109 | }; 110 | 111 | const input = (type, prompt, callback) => { 112 | showKeyboard(); 113 | controlInput.style.display = 'none'; 114 | controlBrowse.removeChild(controlInput); 115 | controlInput.inputActive = true; 116 | controlInput.inputPrompt = prompt; 117 | inputSetValue(''); 118 | controlInput.inputType = type; 119 | controlInput.inputCallback = callback; 120 | controlBrowse.appendChild(controlInput); 121 | controlInput.style.display = 'block'; 122 | setTimeout(scrollBottom, 0); 123 | }; 124 | 125 | const clear = () => { 126 | const elements = controlBrowse.children; 127 | for (let i = elements.length - 2; i > 1; i--) { 128 | const element = elements[i]; 129 | controlBrowse.removeChild(element); 130 | } 131 | }; 132 | 133 | const print = (s) => { 134 | const list = Array.isArray(s); 135 | let line = list ? s.shift() : s; 136 | if (!line) line = ''; 137 | const element = document.createElement('div'); 138 | if (!line) line = '\xa0'; 139 | if (line.charAt(0) === '<') { 140 | element.innerHTML += line; 141 | } else { 142 | const timer = setInterval(() => { 143 | const char = line.charAt(0); 144 | element.innerHTML += char; 145 | line = line.substr(1); 146 | if (!line) clearInterval(timer); 147 | controlBrowse.scrollTop = controlBrowse.scrollHeight; 148 | scrollBottom(); 149 | }, TIME_CHAR); 150 | } 151 | if (list && s.length) setTimeout(print, TIME_LINE, s); 152 | controlBrowse.insertBefore(element, controlInput); 153 | controlBrowse.scrollTop = controlBrowse.scrollHeight; 154 | scrollBottom(); 155 | }; 156 | 157 | const inputKeyboardEvents = { 158 | ESC() { 159 | clear(); 160 | inputSetValue(''); 161 | }, 162 | BACKSPACE() { 163 | inputSetValue(controlInput.inputValue.slice(0, -1)); 164 | }, 165 | ENTER() { 166 | let value = controlInput.inputValue; 167 | if (controlInput.inputType === 'masked') { 168 | value = pad('*', value.length); 169 | } 170 | print(controlInput.inputPrompt + value); 171 | controlInput.style.display = 'none'; 172 | controlInput.inputActive = false; 173 | controlInput.inputCallback(null, value); 174 | }, 175 | CAPS() { 176 | if (controlKeyboard.className === 'caps') { 177 | controlKeyboard.className = ''; 178 | } else { 179 | controlKeyboard.className = 'caps'; 180 | } 181 | }, 182 | KEY(char) { 183 | // Alpha or Digit 184 | if (controlKeyboard.className === 'caps') { 185 | char = char.toUpperCase(); 186 | } 187 | inputSetValue(controlInput.inputValue + char); 188 | }, 189 | }; 190 | 191 | const keyboardClick = (e) => { 192 | let char = e.target.inputChar; 193 | if (char === '_') char = ' '; 194 | let keyName = 'KEY'; 195 | if (char === '<') keyName = 'BACKSPACE'; 196 | if (char === '>') keyName = 'ENTER'; 197 | if (char === '^') keyName = 'CAPS'; 198 | const fn = inputKeyboardEvents[keyName]; 199 | if (fn) fn(char); 200 | e.stopPropagation(); 201 | return false; 202 | }; 203 | 204 | const initKeyboard = () => { 205 | if (!isMobile()) return; 206 | controlKeyboard.style.display = 'block'; 207 | const KEYBOARD_LAYOUT = [ 208 | '1234567890', 209 | 'qwertyuiop', 210 | 'asdfghjkl<', 211 | '^zxcvbnm_>', 212 | ]; 213 | for (let i = 0; i < KEYBOARD_LAYOUT.length; i++) { 214 | const keyboardLine = KEYBOARD_LAYOUT[i]; 215 | const elementLine = document.createElement('div'); 216 | controlKeyboard.appendChild(elementLine); 217 | for (let j = 0; j < keyboardLine.length; j++) { 218 | let char = keyboardLine[j]; 219 | if (char === ' ') char = ' '; 220 | const elementKey = document.createElement('div'); 221 | elementKey.innerHTML = char; 222 | elementKey.inputChar = char; 223 | elementKey.className = 'key'; 224 | elementKey.style.opacity = (i + j) % 2 ? 0.8 : 1; 225 | elementKey.addEventListener('click', keyboardClick); 226 | elementLine.appendChild(elementKey); 227 | } 228 | } 229 | controlBrowse.style.bottom = controlKeyboard.offsetHeight + 'px'; 230 | }; 231 | 232 | document.onkeydown = (event) => { 233 | if (controlInput.inputActive) { 234 | const keyName = KEY_NAME[event.keyCode]; 235 | const fn = inputKeyboardEvents[keyName]; 236 | if (fn) { 237 | fn(); 238 | return false; 239 | } 240 | } 241 | }; 242 | 243 | document.onkeypress = (event) => { 244 | if (controlInput.inputActive) { 245 | const fn = inputKeyboardEvents['KEY']; 246 | const char = String.fromCharCode(event.keyCode); 247 | if (CHARS.includes(char) && fn) { 248 | fn(char); 249 | return false; 250 | } 251 | } 252 | }; 253 | 254 | const blobToBase64 = (blob) => { 255 | const reader = new FileReader(); 256 | reader.readAsDataURL(blob); 257 | return new Promise((resolve) => { 258 | reader.onloadend = () => { 259 | resolve(reader.result); 260 | }; 261 | }); 262 | }; 263 | 264 | const uploadFile = (file, done) => { 265 | blobToBase64(file).then((url) => { 266 | const data = url.substring(url.indexOf(',') + 1); 267 | api.example.uploadFile({ name: file.name, data }).then(done); 268 | }); 269 | }; 270 | 271 | const upload = () => { 272 | const element = document.createElement('form'); 273 | element.style.visibility = 'hidden'; 274 | element.innerHTML = ''; 275 | document.body.appendChild(element); 276 | const fileSelect = document.getElementById('fileSelect'); 277 | fileSelect.click(); 278 | fileSelect.onchange = () => { 279 | const files = Array.from(fileSelect.files); 280 | print('Uploading ' + files.length + ' file(s)'); 281 | files.sort((a, b) => a.size - b.size); 282 | let i = 0; 283 | const uploadNext = () => { 284 | const file = files[i]; 285 | uploadFile(file, () => { 286 | print(`name: ${file.name}, size: ${file.size} done`); 287 | i++; 288 | if (i < files.length) { 289 | return uploadNext(); 290 | } 291 | document.body.removeChild(element); 292 | commandLoop(); 293 | }); 294 | }; 295 | uploadNext(); 296 | }; 297 | }; 298 | 299 | const exec = async (line) => { 300 | const args = line.split(' '); 301 | if (args[0] === 'upload') { 302 | upload(); 303 | } else { 304 | const data = await api.cms.content(args); 305 | print(data); 306 | } 307 | commandLoop(); 308 | }; 309 | 310 | function commandLoop() { 311 | input('command', '.', (err, line) => { 312 | exec(line); 313 | commandLoop(); 314 | }); 315 | } 316 | 317 | const signIn = async () => { 318 | try { 319 | await metacom.load('auth'); 320 | await api.auth.status(); 321 | } catch (err) { 322 | await api.auth.signIn({ login: 'marcus', password: 'marcus' }); 323 | } 324 | await metacom.load('example'); 325 | api.example.on('resmon', (data) => print(JSON.stringify(data))); 326 | api.example.subscribe(); 327 | }; 328 | 329 | window.addEventListener('load', () => { 330 | panelScroll = document.getElementById('panelScroll'); 331 | controlInput = document.getElementById('controlInput'); 332 | controlKeyboard = document.getElementById('controlKeyboard'); 333 | controlBrowse = document.getElementById('controlBrowse'); 334 | controlScroll = document.getElementById('controlScroll'); 335 | initKeyboard(); 336 | initScroll(); 337 | const path = window.location.pathname.substring(1); 338 | print([ 339 | 'Metarhia is a Community, Technology Stack and R&D Center', 340 | 'for Cloud Computing and Distributed Database Systems', 341 | '', 342 | 'Commands: about, fields, team, links, stack, contacts', 343 | ]); 344 | if (path) { 345 | setTimeout(() => { 346 | exec('contacts ' + path); 347 | window.history.replaceState(null, '', '/'); 348 | }, TIME_LINE * 3); 349 | } 350 | signIn(); 351 | commandLoop(); 352 | }); 353 | --------------------------------------------------------------------------------