├── 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 |
--------------------------------------------------------------------------------
/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 |
41 |
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 |
--------------------------------------------------------------------------------